feat: Add QR codes for WhatsApp, Messenger, and Telegram on inbox finish page (#12257)

Added QR code generation for multiple messaging platforms on the inbox
finish setup page. So users can scan QR codes to instantly test their
newly created channels.

**Supported Platforms**

  - **WhatsApp**: QR code for `https://wa.me/{phone_number}`
    - Supports both WhatsApp Cloud and Twilio WhatsApp inboxes
  - **Facebook Messenger**: QR code for `https://m.me/{page_id}`
    - All Facebook page inboxes
  - **Telegram**: QR code for `https://t.me/{bot_name}`
    - All Telegram bot inboxes

**How to test the changes**
You can test these changes by navigating to this URL
`{BASE_URL}/app/accounts/{account_id}/settings/inboxes/new/{inbox_id}/finish`
and simply replacing the inbox ID with one you've already created.

**Preview**

<img width="2432" height="1474" alt="CleanShot 2025-08-21 at 15 40
59@2x"
src="https://github.com/user-attachments/assets/4226133b-9793-48ca-bf79-903b7e003ef3"
/>

---------

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Muhsin Keloth
2025-08-27 11:53:03 +05:30
committed by GitHub
parent 068538e3d6
commit b18341ea6e
8 changed files with 661 additions and 108 deletions

View File

@@ -0,0 +1,277 @@
import { describe, it, expect, vi } from 'vitest';
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { mount } from '@vue/test-utils';
import { useInbox } from '../useInbox';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useTransformKeys');
// Mock the dependencies
const mockStore = createStore({
modules: {
conversations: {
namespaced: false,
getters: {
getSelectedChat: () => ({ inbox_id: 1 }),
},
},
inboxes: {
namespaced: true,
getters: {
getInboxById: () => id => {
const inboxes = {
1: {
id: 1,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'whatsapp_cloud',
},
2: { id: 2, channel_type: INBOX_TYPES.FB },
3: { id: 3, channel_type: INBOX_TYPES.TWILIO, medium: 'sms' },
4: { id: 4, channel_type: INBOX_TYPES.TWILIO, medium: 'whatsapp' },
5: {
id: 5,
channel_type: INBOX_TYPES.EMAIL,
provider: 'microsoft',
},
6: { id: 6, channel_type: INBOX_TYPES.EMAIL, provider: 'google' },
7: {
id: 7,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'default',
},
8: { id: 8, channel_type: INBOX_TYPES.TELEGRAM },
9: { id: 9, channel_type: INBOX_TYPES.LINE },
10: { id: 10, channel_type: INBOX_TYPES.WEB },
11: { id: 11, channel_type: INBOX_TYPES.API },
12: { id: 12, channel_type: INBOX_TYPES.SMS },
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
14: { id: 14, channel_type: INBOX_TYPES.VOICE },
};
return inboxes[id] || null;
},
},
},
},
});
// Mock useMapGetter to return mock store getters
vi.mock('dashboard/composables/store', () => ({
useMapGetter: vi.fn(getter => {
if (getter === 'getSelectedChat') {
return { value: { inbox_id: 1 } };
}
if (getter === 'inboxes/getInboxById') {
return { value: mockStore.getters['inboxes/getInboxById'] };
}
return { value: null };
}),
}));
// Mock useCamelCase to return the data as-is for testing
vi.mock('dashboard/composables/useTransformKeys', () => ({
useCamelCase: vi.fn(data => ({
...data,
channelType: data?.channel_type,
})),
}));
describe('useInbox', () => {
const createTestComponent = inboxId =>
defineComponent({
setup() {
return useInbox(inboxId);
},
render() {
return h('div');
},
});
describe('with current chat context (no inboxId provided)', () => {
it('identifies WhatsApp Cloud channel correctly', () => {
const wrapper = mount(createTestComponent(), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
});
it('returns correct inbox object', () => {
const wrapper = mount(createTestComponent(), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.inbox).toEqual({
id: 1,
channel_type: INBOX_TYPES.WHATSAPP,
provider: 'whatsapp_cloud',
channelType: INBOX_TYPES.WHATSAPP,
});
});
});
describe('with explicit inboxId provided', () => {
it('identifies Facebook inbox correctly', () => {
const wrapper = mount(createTestComponent(2), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAFacebookInbox).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
});
it('identifies Twilio SMS channel correctly', () => {
const wrapper = mount(createTestComponent(3), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isASmsInbox).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
});
it('identifies Twilio WhatsApp channel correctly', () => {
const wrapper = mount(createTestComponent(4), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATwilioChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
});
it('identifies Microsoft email inbox correctly', () => {
const wrapper = mount(createTestComponent(5), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnEmailChannel).toBe(true);
expect(wrapper.vm.isAMicrosoftInbox).toBe(true);
expect(wrapper.vm.isAGoogleInbox).toBe(false);
});
it('identifies Google email inbox correctly', () => {
const wrapper = mount(createTestComponent(6), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnEmailChannel).toBe(true);
expect(wrapper.vm.isAGoogleInbox).toBe(true);
expect(wrapper.vm.isAMicrosoftInbox).toBe(false);
});
it('identifies 360Dialog WhatsApp channel correctly', () => {
const wrapper = mount(createTestComponent(7), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
});
it('identifies all other channel types correctly', () => {
// Test Telegram
let wrapper = mount(createTestComponent(8), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isATelegramChannel).toBe(true);
// Test Line
wrapper = mount(createTestComponent(9), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isALineChannel).toBe(true);
// Test Web Widget
wrapper = mount(createTestComponent(10), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
// Test API
wrapper = mount(createTestComponent(11), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAPIInbox).toBe(true);
// Test SMS
wrapper = mount(createTestComponent(12), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isASmsInbox).toBe(true);
// Test Instagram
wrapper = mount(createTestComponent(13), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
// Test Voice
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).toBe(true);
});
});
describe('edge cases', () => {
it('handles non-existent inbox ID gracefully', () => {
const wrapper = mount(createTestComponent(999), {
global: { plugins: [mockStore] },
});
// useCamelCase still processes null data, so we get an object with channelType: undefined
expect(wrapper.vm.inbox).toEqual({ channelType: undefined });
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
expect(wrapper.vm.isAFacebookInbox).toBe(false);
});
it('handles inbox with no data correctly', () => {
// The mock will return null for non-existent IDs, but useCamelCase processes it
const wrapper = mount(createTestComponent(999), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.inbox.channelType).toBeUndefined();
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
expect(wrapper.vm.isAFacebookInbox).toBe(false);
expect(wrapper.vm.isATelegramChannel).toBe(false);
});
});
describe('return object completeness', () => {
it('returns all expected properties', () => {
const wrapper = mount(createTestComponent(1), {
global: { plugins: [mockStore] },
});
const expectedProperties = [
'inbox',
'isAFacebookInbox',
'isALineChannel',
'isAPIInbox',
'isASmsInbox',
'isATelegramChannel',
'isATwilioChannel',
'isAWebWidgetInbox',
'isAWhatsAppChannel',
'isAMicrosoftInbox',
'isAGoogleInbox',
'isATwilioWhatsAppChannel',
'isAWhatsAppCloudChannel',
'is360DialogWhatsAppChannel',
'isAnEmailChannel',
'isAnInstagramChannel',
'isAVoiceChannel',
];
expectedProperties.forEach(prop => {
expect(wrapper.vm).toHaveProperty(prop);
});
});
});
});

View File

@@ -29,21 +29,24 @@ export const INBOX_FEATURE_MAP = {
};
/**
* Composable for handling macro-related functionality
* @returns {Object} An object containing the getMacroDropdownValues function
* Composable for handling inbox-related functionality
* @param {string|null} inboxId - Optional inbox ID. If not provided, uses current chat's inbox
* @returns {Object} An object containing inbox type checking functions
*/
export const useInbox = () => {
export const useInbox = (inboxId = null) => {
const currentChat = useMapGetter('getSelectedChat');
const inboxGetter = useMapGetter('inboxes/getInboxById');
const inbox = computed(() => {
const inboxId = currentChat.value.inbox_id;
const targetInboxId = inboxId || currentChat.value?.inbox_id;
return useCamelCase(inboxGetter.value(inboxId), { deep: true });
if (!targetInboxId) return null;
return useCamelCase(inboxGetter.value(targetInboxId), { deep: true });
});
const channelType = computed(() => {
return inbox.value.channelType;
return inbox.value?.channelType;
});
const isAPIInbox = computed(() => {
@@ -75,19 +78,19 @@ export const useInbox = () => {
});
const whatsAppAPIProvider = computed(() => {
return inbox.value.provider || '';
return inbox.value?.provider || '';
});
const isAMicrosoftInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'microsoft';
return isAnEmailChannel.value && inbox.value?.provider === 'microsoft';
});
const isAGoogleInbox = computed(() => {
return isAnEmailChannel.value && inbox.value.provider === 'google';
return isAnEmailChannel.value && inbox.value?.provider === 'google';
});
const isATwilioSMSChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
const { medium: medium = '' } = inbox.value || {};
return isATwilioChannel.value && medium === 'sms';
});
@@ -96,7 +99,7 @@ export const useInbox = () => {
});
const isATwilioWhatsAppChannel = computed(() => {
const { medium: medium = '' } = inbox.value;
const { medium: medium = '' } = inbox.value || {};
return isATwilioChannel.value && medium === 'whatsapp';
});

View File

@@ -478,7 +478,10 @@
"MESSAGE": "You can now engage with your customers through your new Channel. Happy supporting",
"BUTTON_TEXT": "Take me there",
"MORE_SETTINGS": "More settings",
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox."
"WEBSITE_SUCCESS": "You have successfully finished creating a website channel. Copy the code shown below and paste it on your website. Next time a customer use the live chat, the conversation will automatically appear on your inbox.",
"WHATSAPP_QR_INSTRUCTION": "Scan the QR code above to quickly test your WhatsApp inbox",
"MESSENGER_QR_INSTRUCTION": "Scan the QR code above to quickly test your Facebook Messenger inbox",
"TELEGRAM_QR_INSTRUCTION": "Scan the QR code above to quickly test your Telegram inbox"
},
"REAUTH": "Reauthorize",
"VIEW": "View",

View File

@@ -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',