From a5ce1edf20d0c7f10fac1ff4727053069ba569af Mon Sep 17 00:00:00 2001 From: Tanmay Deep Sharma Date: Wed, 20 May 2026 15:57:05 +0530 Subject: [PATCH] =?UTF-8?q?feat(voice):=20WhatsApp=20calling=20UI=20?= =?UTF-8?q?=E2=80=94=20inbox=20toggle,=20call=20widget,=20badges?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend for WhatsApp WABA calling: calling status toggle in inbox settings, WhatsApp Call channel tile, voice badges, call widget redesign, and conversation header call button. --- app/javascript/dashboard/api/inboxes.js | 8 ++ .../dashboard/api/specs/contacts.spec.js | 3 +- .../message/bubbles/VoiceCall.vue | 16 ++- .../components/widgets/ChannelItem.vue | 12 +- .../components/widgets/FloatingCallWidget.vue | 53 +++++--- .../conversation/ConversationHeader.vue | 64 +++++++-- .../dashboard/composables/useCallSession.js | 24 ++-- .../dashboard/helper/actionCable.js | 7 +- app/javascript/dashboard/helper/voice.js | 52 +++++++- .../i18n/locale/en/conversation.json | 3 + .../dashboard/i18n/locale/en/inboxMgmt.json | 9 +- .../settings/inbox/ChannelFactory.vue | 2 + .../dashboard/settings/inbox/ChannelList.vue | 7 + .../settings/inbox/channels/WhatsappCall.vue | 11 ++ .../inbox/channels/WhatsappEmbeddedSignup.vue | 32 ++++- .../settingsPage/WhatsappCallingPage.vue | 121 ++++++++++++------ .../store/modules/conversations/actions.js | 13 +- app/javascript/dashboard/stores/calls.js | 7 +- 18 files changed, 350 insertions(+), 94 deletions(-) create mode 100644 app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index cc564fe96..114dbb6f4 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -52,6 +52,14 @@ class Inboxes extends CacheEnabledApiClient { resetSecret(inboxId) { return axios.post(`${this.url}/${inboxId}/reset_secret`); } + + enableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/enable_whatsapp_calling`); + } + + disableWhatsappCalling(inboxId) { + return axios.post(`${this.url}/${inboxId}/disable_whatsapp_calling`); + } } export default new Inboxes(); diff --git a/app/javascript/dashboard/api/specs/contacts.spec.js b/app/javascript/dashboard/api/specs/contacts.spec.js index b21aeb102..f55ecdfaa 100644 --- a/app/javascript/dashboard/api/specs/contacts.spec.js +++ b/app/javascript/dashboard/api/specs/contacts.spec.js @@ -41,7 +41,8 @@ describe('#ContactsAPI', () => { it('#getConversations', () => { contactAPI.getConversations(1); expect(axiosMock.get).toHaveBeenCalledWith( - '/api/v1/contacts/1/conversations' + '/api/v1/contacts/1/conversations', + { params: {} } ); }); diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 44a765f19..aabb25556 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -59,6 +59,10 @@ const isFailed = computed(() => [VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value) ); const isMissedInbound = computed(() => isFailed.value && !isOutbound.value); +const endReason = computed(() => call.value?.endReason); +const wasDeclinedByAgent = computed( + () => isMissedInbound.value && endReason.value === 'agent_rejected' +); const acceptedByAgentId = computed(() => call.value?.acceptedByAgentId); const didCurrentUserAnswer = computed( () => @@ -130,9 +134,15 @@ const subtext = computed(() => { return null; } if (isFailed.value) { - return isOutbound.value - ? t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT') - : t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); + if (isOutbound.value) { + return t('CONVERSATION.VOICE_CALL.NO_ANSWER_OUTBOUND_SUBTEXT'); + } + if (wasDeclinedByAgent.value && displayAgentName.value) { + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_DECLINED_BY', { + agentName: displayAgentName.value, + }); + } + return t('CONVERSATION.VOICE_CALL.MISSED_CALL_INBOUND_SUBTEXT'); } return t('CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET'); }); diff --git a/app/javascript/dashboard/components/widgets/ChannelItem.vue b/app/javascript/dashboard/components/widgets/ChannelItem.vue index 07759183d..2429ebe7b 100644 --- a/app/javascript/dashboard/components/widgets/ChannelItem.vue +++ b/app/javascript/dashboard/components/widgets/ChannelItem.vue @@ -56,6 +56,14 @@ const isActive = computed(() => { return props.enabledFeatures.channel_voice; } + if (key === 'whatsapp_call') { + return ( + props.enabledFeatures.channel_voice && + !!window.chatwootConfig?.whatsappAppId && + window.chatwootConfig.whatsappAppId !== 'none' + ); + } + return [ 'website', 'twilio', @@ -78,12 +86,12 @@ const isComingSoon = computed(() => { }); const isBeta = computed(() => { - return ['tiktok', 'voice'].includes(props.channel.key); + return ['tiktok', 'voice', 'whatsapp_call'].includes(props.channel.key); }); const hasVoiceBadge = computed(() => { return ( - ['voice', 'whatsapp'].includes(props.channel.key) && + ['voice', 'whatsapp_call'].includes(props.channel.key) && !!props.enabledFeatures.channel_voice ); }); diff --git a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue index f2fac68c9..20dc3331c 100644 --- a/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue +++ b/app/javascript/dashboard/components/widgets/FloatingCallWidget.vue @@ -34,9 +34,17 @@ const isWhatsappActive = computed( () => activeCall.value?.provider === 'whatsapp' ); +const primaryIncomingCall = computed(() => + hasActiveCall.value ? null : incomingCalls.value[0] || null +); + +const stackedIncomingCalls = computed(() => + hasActiveCall.value ? incomingCalls.value : incomingCalls.value.slice(1) +); + const mainCardState = computed(() => { if (hasActiveCall.value) return 'ongoing'; - const direction = incomingCalls.value[0]?.callDirection; + const direction = primaryIncomingCall.value?.callDirection; return direction === 'outbound' ? 'outgoing' : 'incoming'; }); @@ -65,12 +73,18 @@ const countryCodeToFlag = code => { const getCallInfo = call => { const conversation = store.getters.getConversationById(call?.conversationId); - const inbox = store.getters['inboxes/getInbox'](conversation?.inbox_id); + // Look up inbox from the call's own inboxId — the conversation can drop out + // of the Vuex store when the user navigates between inbox views, so going + // through `conversation.inbox_id` would lose the inbox name (and fall back + // to the literal "Customer support" string). + const inbox = store.getters['inboxes/getInbox'](call?.inboxId); const sender = conversation?.meta?.sender; - // Inbound WhatsApp calls stash caller info on the call record (from the cable - // payload) so the widget has something to show before the conversation lands. + // `caller` is the snapshot captured when the call first landed (from the + // message sender or the WhatsApp cable payload). It outlives the + // conversation being in the store, so prefer it for display. const caller = call?.caller; - const additional = sender?.additional_attributes || {}; + const additional = + sender?.additional_attributes || caller?.additionalAttributes || {}; const city = additional.city || ''; const countryCode = additional.country_code || ''; const country = @@ -87,17 +101,17 @@ const getCallInfo = call => { conversation, inbox, contactName: - sender?.name || - sender?.phone_number || caller?.name || + sender?.name || caller?.phone || + sender?.phone_number || 'Unknown caller', - phoneNumber: sender?.phone_number || caller?.phone || '', + phoneNumber: caller?.phone || sender?.phone_number || '', inboxName: inbox?.name || 'Customer support', location, countryFlag: countryCodeToFlag(countryCode), hasLocation: locationParts.length > 0, - avatar: sender?.avatar || sender?.thumbnail || caller?.avatar, + avatar: caller?.avatar || sender?.avatar || sender?.thumbnail, }; }; @@ -168,7 +182,10 @@ watch( // Loop the ringtone while an inbound call is unanswered. Stop the moment any // call is active (we joined), every inbound call cleared, or the widget tears -// down. Browser autoplay may reject the first play() if the tab has no prior +// down. The watcher only fires on the boolean transitioning, so additional +// ringing calls arriving while one is already ringing don't restart the audio +// — they silently stack into the UI without producing a fresh ring. +// Browser autoplay may reject the first play() if the tab has no prior // user gesture; that's fine — the visual widget still surfaces the call. const ringtone = new Audio(RINGTONE_URL); ringtone.loop = true; @@ -203,9 +220,9 @@ onBeforeUnmount(stopRingtone); v-if="incomingCalls.length || hasActiveCall" class="fixed ltr:right-4 rtl:left-4 bottom-4 z-50 flex flex-col gap-3 w-[400px]" > - + diff --git a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue index f23e9e6a2..71bc6403d 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ConversationHeader.vue @@ -19,6 +19,7 @@ import { } from 'dashboard/helper/inbox'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; import { useCallsStore } from 'dashboard/stores/calls'; +import { useMapGetter } from 'dashboard/composables/store'; import { useAlert } from 'dashboard/composables'; import { useI18n } from 'vue-i18n'; import { copyTextToClipboard } from 'shared/helpers/clipboard'; @@ -103,16 +104,32 @@ const hasSlaPolicyId = computed(() => props.chat?.sla_policy_id); const callsStore = useCallsStore(); const whatsappCallSession = useWhatsappCallSession(); +const contactsUiFlags = useMapGetter('contacts/getUIFlags'); +const voiceCallProvider = computed(() => getVoiceCallProvider(inbox.value)); +const isVoiceCallInbox = computed(() => voiceCallProvider.value !== null); const isWhatsappVoiceInbox = computed( - () => getVoiceCallProvider(inbox.value) === VOICE_CALL_PROVIDERS.WHATSAPP + () => voiceCallProvider.value === VOICE_CALL_PROVIDERS.WHATSAPP ); -const isWhatsappCallButtonDisabled = computed( - () => - whatsappCallSession.isInitiating.value || - callsStore.hasActiveCall || - callsStore.hasIncomingCall +const isCallButtonDisabled = computed(() => { + if (callsStore.hasActiveCall || callsStore.hasIncomingCall) return true; + if (isWhatsappVoiceInbox.value) { + return whatsappCallSession.isInitiating.value; + } + return contactsUiFlags.value?.isInitiatingCall || false; +}); + +const isCallButtonLoading = computed(() => + isWhatsappVoiceInbox.value + ? whatsappCallSession.isInitiating.value + : !!contactsUiFlags.value?.isInitiatingCall +); + +const callButtonTooltip = computed(() => + isWhatsappVoiceInbox.value + ? t('CONVERSATION.HEADER.WHATSAPP_CALL') + : t('CONVERSATION.HEADER.VOICE_CALL') ); const startWhatsappCall = async () => { @@ -151,6 +168,31 @@ const startWhatsappCall = async () => { } }; +const startTwilioCall = async () => { + if (contactsUiFlags.value?.isInitiatingCall) return; + try { + const response = await store.dispatch('contacts/initiateCall', { + contactId: currentContact.value.id, + inboxId: inbox.value?.id, + conversationId: currentChat.value.id, + }); + + callsStore.addCall({ + callSid: response?.call_sid, + conversationId: response?.conversation_id ?? currentChat.value.id, + inboxId: inbox.value?.id, + callDirection: 'outbound', + }); + } catch (error) { + useAlert(error?.message || t('CONVERSATION.HEADER.VOICE_CALL_FAILED')); + } +}; + +const startCall = () => { + if (isWhatsappVoiceInbox.value) return startWhatsappCall(); + return startTwilioCall(); +}; + const copyConversationId = async () => { try { await copyTextToClipboard(String(props.chat.id)); @@ -230,16 +272,16 @@ const copyConversationId = async () => { class="hidden md:flex" /> diff --git a/app/javascript/dashboard/composables/useCallSession.js b/app/javascript/dashboard/composables/useCallSession.js index ab3dbdfb8..553a99c03 100644 --- a/app/javascript/dashboard/composables/useCallSession.js +++ b/app/javascript/dashboard/composables/useCallSession.js @@ -14,10 +14,6 @@ import { handleVoiceCallCreated } from 'dashboard/helper/voice'; import Timer from 'dashboard/helper/Timer'; const isWhatsappCall = call => call?.provider === 'whatsapp'; -// Calls seeded after a refresh / arriving via message.updated may lack provider -// metadata. Treat anything that has a callId (Meta's wacid) as WhatsApp — the -// Twilio path keys off provider_call_id (a CA…) and never sets callId. -const isWhatsappLikeCall = call => isWhatsappCall(call) || !!call?.callId; // Dismissed call sids must not be re-seeded by the conversation-load watcher. // Lives at module scope so all consumers share the same set. @@ -86,7 +82,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => { const endCall = async ({ conversationId, inboxId, callSid }) => { const call = findCall(callSid); - if (isWhatsappLikeCall(call)) { + if (isWhatsappCall(call)) { // Pass call.callId so a wiped module state (e.g. a prior accept attempt // tore down the WebRTC session) doesn't stop us hitting /terminate. await whatsappSession.endActiveCall(call?.callId); @@ -111,13 +107,13 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => { // cleanup() and destroy the live outbound session. Outbound *Twilio* // calls still need joinConference + joinClientCall (FloatingCallWidget // auto-joins them), so don't short-circuit those. - if (call?.callDirection === 'outbound' && isWhatsappLikeCall(call)) { + if (call?.callDirection === 'outbound' && isWhatsappCall(call)) { return null; } globalIsJoining.value = true; try { - if (isWhatsappLikeCall(call)) { + if (isWhatsappCall(call)) { await whatsappSession.acceptIncomingCall({ callId: call.callId, sdpOffer: call.sdpOffer, @@ -170,7 +166,7 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => { const rejectIncomingCall = async callSid => { const call = findCall(callSid); try { - if (isWhatsappLikeCall(call) && call?.callId) { + if (isWhatsappCall(call) && call?.callId) { if (call.callDirection === 'outbound') { // Outbound calls that are still ringing must be terminated, not // rejected (reject is the inbound-side verb on Meta's API). @@ -178,6 +174,15 @@ const buildCallActions = ({ callsStore, whatsappSession, t }) => { } else { await whatsappSession.rejectIncomingCall(call.callId); } + } else if (call?.inboxId && call?.conversationId) { + // Twilio incoming reject: agent hasn't joined the Device yet, so + // endClientCall is a no-op. End the conference server-side instead + // so Twilio hangs up the inbound leg. + await VoiceAPI.leaveConference({ + inboxId: call.inboxId, + conversationId: call.conversationId, + callSid, + }); } else { TwilioVoiceClient.endClientCall(); } @@ -234,13 +239,14 @@ export function useCallSession() { const seedCallsFromHydratedMessages = () => { const conversations = store.getters.getAllConversations || []; const currentUserId = store.getters.getCurrentUserID; + const currentUserAvailability = store.getters.getCurrentUserAvailability; conversations.forEach(conv => { (conv.messages || []).forEach(msg => { if (msg.content_type !== 'voice_call') return; if (msg.call?.status !== 'ringing') return; const callSid = msg.call?.provider_call_id; if (callSid && dismissedCallSids.has(callSid)) return; - handleVoiceCallCreated(msg, currentUserId); + handleVoiceCallCreated(msg, currentUserId, currentUserAvailability); }); }); }; diff --git a/app/javascript/dashboard/helper/actionCable.js b/app/javascript/dashboard/helper/actionCable.js index e53a30053..76106ba02 100644 --- a/app/javascript/dashboard/helper/actionCable.js +++ b/app/javascript/dashboard/helper/actionCable.js @@ -217,9 +217,14 @@ class ActionCableConnector extends BaseActionCableConnector { this.app.$store.dispatch('teams/revalidate', { newKey: keys.team }); }; - // eslint-disable-next-line class-methods-use-this onVoiceCallIncoming = data => { if (data?.provider !== 'whatsapp') return; + // Defense in depth: the server already filters to online agent streams, + // but if anything ever broadcasts to a broader stream (e.g. account-wide), + // an agent who's set availability=offline/busy shouldn't ring. + const availability = this.app.$store.getters.getCurrentUserAvailability; + if (availability !== 'online') return; + useCallsStore().addCall({ callSid: data.call_id, callId: data.id, diff --git a/app/javascript/dashboard/helper/voice.js b/app/javascript/dashboard/helper/voice.js index f479267ea..849738f6f 100644 --- a/app/javascript/dashboard/helper/voice.js +++ b/app/javascript/dashboard/helper/voice.js @@ -45,6 +45,29 @@ const shouldShowCall = ({ return !isAssignedToAnotherAgent(assigneeId, currentUserId); }; +// Offline/busy agents shouldn't get a ringing popup for inbound calls, but +// outbound calls always belong to the initiator regardless of their status, +// and existing (already-surfaced) calls keep going so a status change +// mid-call doesn't yank away an active widget. +const shouldRingInbound = (callDirection, currentUserAvailability) => { + if (callDirection === 'outbound') return true; + return currentUserAvailability === 'online'; +}; + +function extractCallerSnapshot(message) { + // Snapshot caller info from the message at add-time so the widget can keep + // rendering it after the user navigates away from a conversation list that + // had the conversation hydrated (and Vuex evicts it from the store). + const sender = message?.sender; + if (!sender) return null; + return { + name: sender.name, + phone: sender.phone_number, + avatar: sender.avatar || sender.thumbnail, + additionalAttributes: sender.additional_attributes || {}, + }; +} + function extractCallData(message) { const call = message?.call || {}; return { @@ -54,12 +77,18 @@ function extractCallData(message) { status: call.status, callDirection: call.direction === 'outgoing' ? 'outbound' : 'inbound', conversationId: message?.conversation_id, + inboxId: message?.inbox_id ?? message?.conversation?.inbox_id, assigneeId: extractAssigneeId(message?.conversation), senderId: message?.sender?.id, + caller: extractCallerSnapshot(message), }; } -export function handleVoiceCallCreated(message, currentUserId) { +export function handleVoiceCallCreated( + message, + currentUserId, + currentUserAvailability +) { if (!isVoiceCallMessage(message)) return; const { @@ -68,6 +97,7 @@ export function handleVoiceCallCreated(message, currentUserId) { provider, callDirection, conversationId, + inboxId, assigneeId, senderId, } = extractCallData(message); @@ -83,25 +113,37 @@ export function handleVoiceCallCreated(message, currentUserId) { return; } + if (!shouldRingInbound(callDirection, currentUserAvailability)) return; + const callsStore = useCallsStore(); callsStore.addCall({ callSid, callId, provider, conversationId, + inboxId, callDirection, senderId, + caller: extractCallerSnapshot(message), }); } -export function handleVoiceCallUpdated(commit, message, currentUserId) { +export function handleVoiceCallUpdated( + commit, + message, + currentUserId, + currentUserAvailability +) { if (!isVoiceCallMessage(message)) return; const { callSid, + callId, + provider, status, callDirection, conversationId, + inboxId, assigneeId, senderId, } = extractCallData(message); @@ -129,11 +171,17 @@ export function handleVoiceCallUpdated(commit, message, currentUserId) { } if (status === 'ringing') { + if (!shouldRingInbound(callDirection, currentUserAvailability)) return; + callsStore.addCall({ callSid, + callId, + provider, conversationId, + inboxId, callDirection, senderId, + caller: extractCallerSnapshot(message), }); } } diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 4d9543f6a..c7017608d 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -83,6 +83,7 @@ "NO_ANSWER_OUTBOUND_SUBTEXT": "Contact didn't pick up", "MISSED_CALL": "Missed call", "MISSED_CALL_INBOUND_SUBTEXT": "No agent picked up", + "MISSED_CALL_DECLINED_BY": "Declined by {agentName}", "CALL_ENDED": "Call ended", "NOT_ANSWERED_YET": "Not answered yet", "CALLING": "Calling…", @@ -107,6 +108,8 @@ "SNOOZED_UNTIL_NEXT_REPLY": "Snoozed until next reply", "WHATSAPP_CALL": "Start WhatsApp call", "WHATSAPP_CALL_FAILED": "Could not start the WhatsApp call.", + "VOICE_CALL": "Start call", + "VOICE_CALL_FAILED": "Could not start the call.", "WHATSAPP_CALL_PERMISSION_REQUESTED": "Sent a call permission request to the contact. Try again once they accept.", "WHATSAPP_CALL_PERMISSION_PENDING": "Call permission request already sent recently. Try again once the contact accepts.", "SLA_STATUS": { diff --git a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json index bd118557c..cd7e46330 100644 --- a/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json +++ b/app/javascript/dashboard/i18n/locale/en/inboxMgmt.json @@ -308,6 +308,7 @@ "AUTH_PROCESSING": "Authenticating with Meta", "WAITING_FOR_BUSINESS_INFO": "Please complete business setup in the Meta window...", "PROCESSING": "Setting up your WhatsApp Business Account", + "ENABLING_CALLING": "Enabling WhatsApp Calling on your number…", "LOADING_SDK": "Loading Facebook SDK...", "CANCELLED": "WhatsApp Signup was cancelled", "SUCCESS_TITLE": "WhatsApp Business Account Connected!", @@ -317,7 +318,8 @@ "AUTH_NOT_COMPLETED": "Authentication not completed. Please restart the process.", "SUCCESS_FALLBACK": "WhatsApp Business Account has been successfully configured", "MANUAL_FALLBACK": "If your number is already connected to the WhatsApp Business Platform (API), or if you’re a tech provider onboarding your own number, please use the {link} flow", - "MANUAL_LINK_TEXT": "manual setup flow" + "MANUAL_LINK_TEXT": "manual setup flow", + "CALLING_ENABLE_FAILED": "Your WhatsApp inbox is ready, but voice calling couldn't be turned on — this number isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then turn calling on from the inbox's Calls settings." }, "API": { "ERROR_MESSAGE": "We were not able to save the WhatsApp channel" @@ -465,6 +467,10 @@ "TITLE": "WhatsApp", "DESCRIPTION": "Support your customers on WhatsApp" }, + "WHATSAPP_CALL": { + "TITLE": "WhatsApp Call", + "DESCRIPTION": "Take voice calls on your WhatsApp number" + }, "EMAIL": { "TITLE": "Email", "DESCRIPTION": "Connect with Gmail, Outlook, or other providers" @@ -654,6 +660,7 @@ "LABEL": "Enable WhatsApp Calling", "DESCRIPTION": "Allow agents to receive and place WhatsApp Cloud calls on this inbox. Customers can call this business number directly from WhatsApp." }, + "ENABLE_FAILED": "Voice calling couldn't be turned on for this number — it isn't enrolled in the WhatsApp Business Calling API yet. Reach out to Meta or your WhatsApp Business Solution Provider to onboard it, then try again.", "PHONE_NUMBER": { "LABEL": "Business phone number", "HELP_TEXT": "WhatsApp number that customers will call." diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue index 7d1d58854..969800eee 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelFactory.vue @@ -7,6 +7,7 @@ import Api from './channels/Api.vue'; import Email from './channels/Email.vue'; import Sms from './channels/Sms.vue'; import Whatsapp from './channels/Whatsapp.vue'; +import WhatsappCall from './channels/WhatsappCall.vue'; import Line from './channels/Line.vue'; import Telegram from './channels/Telegram.vue'; import Instagram from './channels/Instagram.vue'; @@ -21,6 +22,7 @@ const channelViewList = { email: Email, sms: Sms, whatsapp: Whatsapp, + whatsapp_call: WhatsappCall, line: Line, telegram: Telegram, instagram: Instagram, diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue index e2ebd27cd..de0c6059b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/ChannelList.vue @@ -95,6 +95,13 @@ const channelList = computed(() => { icon: 'i-woot-voice', }); + channels.push({ + key: 'whatsapp_call', + title: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.TITLE'), + description: t('INBOX_MGMT.ADD.AUTH.CHANNEL.WHATSAPP_CALL.DESCRIPTION'), + icon: 'i-woot-whatsapp', + }); + return channels; }); diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue new file mode 100644 index 000000000..c27cd7d1f --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappCall.vue @@ -0,0 +1,11 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue index cf5c1310e..668e0709a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/channels/WhatsappEmbeddedSignup.vue @@ -7,6 +7,7 @@ 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 InboxesAPI from 'dashboard/api/inboxes'; import { parseAPIErrorResponse } from 'dashboard/store/utils/api'; import globalConstants from 'dashboard/constants/globals.js'; import { @@ -16,6 +17,13 @@ import { isValidBusinessData, } from './whatsapp/utils'; +const props = defineProps({ + enableCallingOnComplete: { + type: Boolean, + default: false, + }, +}); + const store = useStore(); const router = useRouter(); const { t } = useI18n(); @@ -65,11 +73,27 @@ const handleSignupCancellation = () => { isAuthenticating.value = false; }; -const handleSignupSuccess = inboxData => { - isProcessing.value = false; - isAuthenticating.value = false; +const enableCallingForInbox = async inboxId => { + try { + await InboxesAPI.enableWhatsappCalling(inboxId); + } catch (_) { + useAlert( + t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.CALLING_ENABLE_FAILED') + ); + } +}; +const handleSignupSuccess = async inboxData => { if (inboxData && inboxData.id) { + if (props.enableCallingOnComplete) { + isProcessing.value = true; + processingMessage.value = t( + 'INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.ENABLING_CALLING' + ); + await enableCallingForInbox(inboxData.id); + } + isProcessing.value = false; + isAuthenticating.value = false; useAlert(t('INBOX_MGMT.FINISH.MESSAGE')); router.replace({ name: 'settings_inboxes_add_agents', @@ -79,6 +103,8 @@ const handleSignupSuccess = inboxData => { }, }); } else { + isProcessing.value = false; + isAuthenticating.value = false; useAlert(t('INBOX_MGMT.ADD.WHATSAPP.EMBEDDED_SIGNUP.SUCCESS_FALLBACK')); router.replace({ name: 'settings_inbox_list', diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue index 2967209a2..794e4a261 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/WhatsappCallingPage.vue @@ -1,5 +1,6 @@