diff --git a/app/controllers/api/v1/accounts/agent_bots_controller.rb b/app/controllers/api/v1/accounts/agent_bots_controller.rb index de3d10081..3f0309024 100644 --- a/app/controllers/api/v1/accounts/agent_bots_controller.rb +++ b/app/controllers/api/v1/accounts/agent_bots_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :check_authorization before_action :agent_bot, except: [:index, :create] diff --git a/app/controllers/api/v1/accounts/captain/preferences_controller.rb b/app/controllers/api/v1/accounts/captain/preferences_controller.rb index 482b001d6..3b7fafad5 100644 --- a/app/controllers/api/v1/accounts/captain/preferences_controller.rb +++ b/app/controllers/api/v1/accounts/captain/preferences_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::Captain::PreferencesController < Api::V1::Accounts::BaseController - before_action :current_account before_action :authorize_account_update, only: [:update] def show diff --git a/app/controllers/api/v1/accounts/labels_controller.rb b/app/controllers/api/v1/accounts/labels_controller.rb index 6889d30a4..f678fee42 100644 --- a/app/controllers/api/v1/accounts/labels_controller.rb +++ b/app/controllers/api/v1/accounts/labels_controller.rb @@ -1,5 +1,4 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController - before_action :current_account before_action :fetch_label, except: [:index, :create] before_action :check_authorization diff --git a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb index 580ae77c6..46d89a1ba 100644 --- a/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb +++ b/app/controllers/api/v1/accounts/whatsapp/authorizations_controller.rb @@ -1,4 +1,5 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts::BaseController + before_action :ensure_embedded_signup_enabled # Reconfiguring/reauthorizing a live inbox swaps its credentials, so restrict it to admins. before_action :check_admin_authorization?, if: -> { params[:inbox_id].present? } before_action :fetch_and_validate_inbox, if: -> { params[:inbox_id].present? } @@ -18,6 +19,13 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: private + def ensure_embedded_signup_enabled + return unless ChatwootApp.chatwoot_cloud? + return if Current.account.feature_enabled?('whatsapp_embedded_signup_inbox_creation') + + raise Pundit::NotAuthorizedError + end + def process_embedded_signup service = Whatsapp::EmbeddedSignupService.new( account: Current.account, @@ -44,8 +52,7 @@ class Api::V1::Accounts::Whatsapp::AuthorizationsController < Api::V1::Accounts: def can_reconfigure_channel? channel = @inbox.channel return false unless channel.provider == 'whatsapp_cloud' - - # Reconfiguring a live embedded-signup channel requires the feature flag. + return true if ChatwootApp.chatwoot_cloud? return Current.account.feature_enabled?('whatsapp_reconfigure') if channel.provider_config['source'] == 'embedded_signup' true diff --git a/app/controllers/webhooks/whatsapp_controller.rb b/app/controllers/webhooks/whatsapp_controller.rb index ee71f3c92..8fd18678c 100644 --- a/app/controllers/webhooks/whatsapp_controller.rb +++ b/app/controllers/webhooks/whatsapp_controller.rb @@ -47,18 +47,10 @@ class Webhooks::WhatsappController < ActionController::API metadata = params.dig(:entry, 0, :changes, 0, :value, :metadata) return if metadata.blank? - phone_number = normalized_phone_number(metadata[:display_phone_number]) - phone_number_id = metadata[:phone_number_id] - channel = Channel::Whatsapp.find_by(phone_number: phone_number) - - return channel if channel && channel.provider_config['phone_number_id'] == phone_number_id - end - - def normalized_phone_number(phone_number) - return if phone_number.blank? - - phone_number = phone_number.to_s - phone_number.start_with?('+') ? phone_number : "+#{phone_number}" + Whatsapp::WebhookChannelFinderService.new( + display_phone_number: metadata[:display_phone_number], + phone_number_id: metadata[:phone_number_id] + ).perform end def inactive_whatsapp_number? diff --git a/app/javascript/dashboard/api/calls.js b/app/javascript/dashboard/api/calls.js new file mode 100644 index 000000000..aa220bdee --- /dev/null +++ b/app/javascript/dashboard/api/calls.js @@ -0,0 +1,14 @@ +/* global axios */ +import ApiClient from './ApiClient'; + +class CallsAPI extends ApiClient { + constructor() { + super('calls', { accountScoped: true }); + } + + get(params = {}) { + return axios.get(this.url, { params }); + } +} + +export default new CallsAPI(); diff --git a/app/javascript/dashboard/api/captain/agentSessions.js b/app/javascript/dashboard/api/captain/agentSessions.js new file mode 100644 index 000000000..a557b5938 --- /dev/null +++ b/app/javascript/dashboard/api/captain/agentSessions.js @@ -0,0 +1,9 @@ +import ApiClient from '../ApiClient'; + +class CaptainAgentSessions extends ApiClient { + constructor() { + super('captain/agent_sessions', { accountScoped: true }); + } +} + +export default new CaptainAgentSessions(); diff --git a/app/javascript/dashboard/api/captain/assistant.js b/app/javascript/dashboard/api/captain/assistant.js index 1fc17798d..5af6110ab 100644 --- a/app/javascript/dashboard/api/captain/assistant.js +++ b/app/javascript/dashboard/api/captain/assistant.js @@ -26,15 +26,18 @@ class CaptainAssistant extends ApiClient { }); } - getStats({ assistantId, range }) { - return axios.get(`${this.url}/${assistantId}/stats`, { + getStats({ assistantId, range, signal }) { + const requestConfig = { params: { range, timezone_offset: getTimezoneOffset() }, - }); + }; + if (signal) requestConfig.signal = signal; + + return axios.get(`${this.url}/${assistantId}/stats`, requestConfig); } - getSummary({ assistantId, range }) { + getSummary({ assistantId, range, stats }) { return axios.get(`${this.url}/${assistantId}/summary`, { - params: { range, timezone_offset: getTimezoneOffset() }, + params: { range, timezone_offset: getTimezoneOffset(), stats }, }); } diff --git a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js index ec24aae34..d458c1e5d 100644 --- a/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js +++ b/app/javascript/dashboard/api/channel/whatsapp/whatsappCallsAPI.js @@ -10,10 +10,13 @@ class WhatsappCallsAPI extends ApiClient { return axios.get(`${this.url}/${callId}`).then(r => r.data); } - initiate(conversationId, sdpOffer) { + // Either conversationId, or contactId + inboxId to let the BE resolve the conversation. + initiate({ conversationId, contactId, inboxId }, sdpOffer) { return axios .post(`${this.url}/initiate`, { conversation_id: conversationId, + contact_id: contactId, + inbox_id: inboxId, sdp_offer: sdpOffer, }) .then(r => r.data); diff --git a/app/javascript/dashboard/components-next/Calls/CallListItem.vue b/app/javascript/dashboard/components-next/Calls/CallListItem.vue new file mode 100644 index 000000000..dd2b3af37 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallListItem.vue @@ -0,0 +1,240 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue new file mode 100644 index 000000000..6eaa81b7b --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallRecordingPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue new file mode 100644 index 000000000..b0da9c41e --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallStatusBadge.vue @@ -0,0 +1,56 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue new file mode 100644 index 000000000..f57d154eb --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsEmptyState.vue @@ -0,0 +1,34 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue new file mode 100644 index 000000000..4d01b0500 --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/CallsFilterBar.vue @@ -0,0 +1,250 @@ + + + diff --git a/app/javascript/dashboard/components-next/Calls/constants.js b/app/javascript/dashboard/components-next/Calls/constants.js new file mode 100644 index 000000000..f7399bfef --- /dev/null +++ b/app/javascript/dashboard/components-next/Calls/constants.js @@ -0,0 +1,51 @@ +import { + VOICE_CALL_STATUS, + VOICE_CALL_DIRECTION, +} from 'dashboard/components-next/message/constants'; + +export const CALL_KIND = { + ONGOING: 'ongoing', + INCOMING: 'incoming', + OUTGOING: 'outgoing', + MISSED: 'missed', + NO_REPLY: 'no_reply', + FAILED: 'failed', +}; + +// The API returns display values: status (ringing/in-progress/completed/ +// no-answer/failed) and direction (inbound/outbound). The list UI presents +// them as a single "kind" per row. +export const getCallKind = call => { + if ( + [VOICE_CALL_STATUS.RINGING, VOICE_CALL_STATUS.IN_PROGRESS].includes( + call.status + ) + ) { + return CALL_KIND.ONGOING; + } + if ( + [VOICE_CALL_STATUS.FAILED, VOICE_CALL_STATUS.REJECTED].includes(call.status) + ) { + return CALL_KIND.FAILED; + } + const isInbound = call.direction === VOICE_CALL_DIRECTION.INBOUND; + if (call.status === VOICE_CALL_STATUS.NO_ANSWER) { + return isInbound ? CALL_KIND.MISSED : CALL_KIND.NO_REPLY; + } + return isInbound ? CALL_KIND.INCOMING : CALL_KIND.OUTGOING; +}; + +// Filter chips map to the status/direction params supported by CallFinder. +export const CALL_ACTIVITY_PARAMS = { + missed: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.INBOUND, + }, + no_reply: { + status: VOICE_CALL_STATUS.NO_ANSWER, + direction: VOICE_CALL_DIRECTION.OUTBOUND, + }, + incoming: { direction: VOICE_CALL_DIRECTION.INBOUND }, + outgoing: { direction: VOICE_CALL_DIRECTION.OUTBOUND }, + in_progress: { status: VOICE_CALL_STATUS.IN_PROGRESS }, +}; diff --git a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue index 7e2b6f0c4..5c7ec691d 100644 --- a/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue +++ b/app/javascript/dashboard/components-next/Contacts/VoiceCallButton.vue @@ -16,7 +16,6 @@ import { useAlert } from 'dashboard/composables'; import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper'; import { useCallsStore } from 'dashboard/stores/calls'; import { useWhatsappCallSession } from 'dashboard/composables/useWhatsappCallSession'; -import ContactAPI from 'dashboard/api/contacts'; import Button from 'dashboard/components-next/button/Button.vue'; import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; @@ -83,39 +82,18 @@ const navigateToConversation = conversationId => { const whatsappCallSession = useWhatsappCallSession(); -// Find the most recent open conversation for this contact in the picked inbox. -// WhatsApp /initiate is conversation-scoped (unlike Twilio's contact-scoped path). -// Pass inboxId so the BE applies the filter before the 20-row cap — without it, -// contacts whose latest WhatsApp conversation falls outside the 20 most recent -// across all inboxes would be treated as having no conversation. -const findWhatsappConversationId = async inboxId => { - const { data } = await ContactAPI.getConversations(props.contactId, { - inboxId, - }); - const conversations = data?.payload || []; - const match = [...conversations].sort( - (a, b) => (b.last_activity_at || 0) - (a.last_activity_at || 0) - )[0]; - return match?.id || null; -}; - const startWhatsappCall = async (inboxId, conversationIdHint) => { - // WhatsApp /initiate is conversation-scoped, so we must hand it a - // conversation. Use the caller's hint when given (in-conversation flow); - // otherwise pick the most recent one in the inbox. - const conversationId = - conversationIdHint || (await findWhatsappConversationId(inboxId)); - if (!conversationId) { - useAlert(t('CONTACT_PANEL.CALL_FAILED')); - return; - } - - const response = - await whatsappCallSession.initiateOutboundCall(conversationId); + const response = await whatsappCallSession.initiateOutboundCall( + conversationIdHint + ? { conversationId: conversationIdHint } + : { contactId: props.contactId, inboxId } + ); // The composable returns { status: 'locked' } when an init is already in // flight or a call is already active; treat that as a soft no-op rather than // claiming success. if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; + + const conversationId = response?.conversation_id || conversationIdHint; if (!response?.id) { // Permission template path returns no call id. Mirror the header button and // surface whether the request was just sent or is already pending instead of diff --git a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue index 02a00c703..2446e0e2b 100644 --- a/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue +++ b/app/javascript/dashboard/components-next/NewConversation/ComposeConversation.vue @@ -234,6 +234,7 @@ onMounted(() => resetContacts()); ref="popoverRef" :align="align" :show-content-border="false" + :close-on-scroll="false" @show="onPopoverShow" @hide="onPopoverHide" > diff --git a/app/javascript/dashboard/components-next/audio/AudioPlayer.vue b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue new file mode 100644 index 000000000..c716ffbfb --- /dev/null +++ b/app/javascript/dashboard/components-next/audio/AudioPlayer.vue @@ -0,0 +1,158 @@ + + + diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue index cf66a0a2f..9a68b71ce 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/MetricCard.vue @@ -9,6 +9,7 @@ const props = defineProps({ // null = neutral, true = good direction, false = bad direction trendGood: { type: Boolean, default: null }, clickable: { type: Boolean, default: false }, + loading: { type: Boolean, default: false }, }); const emit = defineEmits(['click']); @@ -45,7 +46,11 @@ const onActivate = () => { class="transition-opacity opacity-0 cursor-help i-lucide-info size-3.5 text-n-slate-10 group-hover:opacity-100" /> -
+
+
+
+
+
diff --git a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue index df336a7e4..5e868956f 100644 --- a/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue +++ b/app/javascript/dashboard/components-next/captain/pageComponents/overview/WelcomeCard.vue @@ -9,6 +9,10 @@ const props = defineProps({ type: String, default: '30', }, + stats: { + type: Object, + default: null, + }, }); const route = useRoute(); @@ -20,22 +24,41 @@ const assistantId = computed(() => route.params.assistantId); const welcomeMarkdown = ref(''); const isLoading = ref(false); +// Increments on every fetch so a slow response for a superseded +// range/stats/assistant can't overwrite the latest request's state. +let fetchToken = 0; + const fetchSummary = async () => { + fetchToken += 1; + const token = fetchToken; + + if (!props.stats) { + welcomeMarkdown.value = ''; + isLoading.value = false; + return; + } + isLoading.value = true; + let message = ''; try { const { data } = await CaptainAssistant.getSummary({ assistantId: assistantId.value, range: props.range, + stats: props.stats, }); - welcomeMarkdown.value = data.message ?? ''; + message = data.message ?? ''; } catch { - welcomeMarkdown.value = ''; - } finally { - isLoading.value = false; + message = ''; } + + if (token !== fetchToken) return; + welcomeMarkdown.value = message; + isLoading.value = false; }; -watch([() => props.range, assistantId], fetchSummary, { immediate: true }); +watch([() => props.range, () => props.stats, assistantId], fetchSummary, { + immediate: true, +}); // Render through the shared markdown formatter (html disabled, so it is safe) // used everywhere else for Captain output, instead of a bespoke parser. It diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue index 4d526d558..1390ea0f7 100644 --- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue +++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue @@ -3,8 +3,13 @@ import { ref, computed, onMounted, watch } from 'vue'; import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; -import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper'; -import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages'; +import { + isTwilioComplete, + isTwilioMediaTemplate, + getTwilioMediaVariableKey, + getTwilioMediaUrl, + applyTwilioMediaFilename, +} from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -40,30 +45,23 @@ const templateBody = computed(() => { return props.template.body || ''; }); -const hasMediaTemplate = computed(() => { - return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA; -}); +// Media-template detection and variable extraction are shared with the mobile +// app via @chatwoot/utils. +const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template)); const hasVariables = computed(() => { return templateBody.value?.match(VARIABLE_PATTERN) !== null; }); -const mediaVariableKey = computed(() => { - if (!hasMediaTemplate.value) return null; - const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0]; - if (!mediaUrl) return null; - return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; -}); +const mediaVariableKey = computed(() => + getTwilioMediaVariableKey(props.template) +); -const hasMediaVariable = computed(() => { - return hasMediaTemplate.value && mediaVariableKey.value !== null; -}); +const hasMediaVariable = computed(() => mediaVariableKey.value !== null); -const templateMediaUrl = computed(() => { - if (!hasMediaTemplate.value) return ''; - - return props.template?.types?.['twilio/media']?.media?.[0] || ''; -}); +const templateMediaUrl = computed(() => + hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : '' +); const variablePattern = computed(() => { if (!hasVariables.value) return []; @@ -83,26 +81,10 @@ const renderedTemplate = computed(() => { return rendered; }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaVariable.value) return false; - - if (hasVariables.value) { - const hasEmptyVariable = variablePattern.value.some( - variable => !processedParams.value[variable] - ); - if (hasEmptyVariable) return true; - } - - if ( - hasMediaVariable.value && - mediaVariableKey.value && - !processedParams.value[mediaVariableKey.value] - ) { - return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isTwilioComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { @@ -135,19 +117,11 @@ const sendMessage = () => { const { friendly_name, language } = props.template; - // Process parameters and extract filename from media URL if needed - const processedParameters = { ...processedParams.value }; - - // For media templates, extract filename from full URL - if ( - hasMediaVariable.value && - mediaVariableKey.value && - processedParameters[mediaVariableKey.value] - ) { - processedParameters[mediaVariableKey.value] = extractFilenameFromUrl( - processedParameters[mediaVariableKey.value] - ); - } + // For media templates, reduce the media URL to a filename before sending. + const processedParameters = applyTwilioMediaFilename( + props.template, + processedParams.value + ); const payload = { message: renderedTemplate.value, diff --git a/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue new file mode 100644 index 000000000..752616109 --- /dev/null +++ b/app/javascript/dashboard/components-next/message/CaptainGenerationDetails.vue @@ -0,0 +1,342 @@ + + + diff --git a/app/javascript/dashboard/components-next/message/bubbles/Base.vue b/app/javascript/dashboard/components-next/message/bubbles/Base.vue index 457b583ea..e799bd755 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/Base.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/Base.vue @@ -2,6 +2,7 @@ import { computed } from 'vue'; import MessageMeta from '../MessageMeta.vue'; +import CaptainGenerationDetails from '../CaptainGenerationDetails.vue'; import { emitter } from 'shared/helpers/mitt'; import { useMessageContext } from '../provider.js'; @@ -9,16 +10,38 @@ import { useI18n } from 'vue-i18n'; import MessageFormatter from 'shared/helpers/MessageFormatter.js'; import { BUS_EVENTS } from 'shared/constants/busEvents'; -import { MESSAGE_VARIANTS, ORIENTATION } from '../constants'; +import { MESSAGE_VARIANTS, ORIENTATION, SENDER_TYPES } from '../constants'; const props = defineProps({ hideMeta: { type: Boolean, default: false }, }); -const { variant, orientation, inReplyTo, shouldGroupWithNext } = - useMessageContext(); +const { + variant, + orientation, + inReplyTo, + shouldGroupWithNext, + id, + sender, + senderType, +} = useMessageContext(); const { t } = useI18n(); +const isCaptainMessage = computed( + () => + (sender.value?.type ?? senderType.value) === SENDER_TYPES.CAPTAIN_ASSISTANT +); + +const metaColorClass = computed(() => + variant.value === MESSAGE_VARIANTS.PRIVATE + ? 'text-n-amber-12/50' + : 'text-n-slate-11' +); + +const emailMetaClass = computed(() => + variant.value === MESSAGE_VARIANTS.EMAIL ? 'px-3 pb-3' : '' +); + const varaintBaseMap = { [MESSAGE_VARIANTS.AGENT]: 'bg-n-solid-blue text-n-slate-12', [MESSAGE_VARIANTS.PRIVATE]: @@ -114,16 +137,21 @@ const replyToPreview = computed(() => { />
- +
diff --git a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue index 34eb1eaff..1a127d9e6 100644 --- a/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue +++ b/app/javascript/dashboard/components-next/message/bubbles/VoiceCall.vue @@ -263,9 +263,9 @@ const handleCallBack = async () => { if (!canCallBack.value || isInitiatingCall.value) return; try { if (isWhatsapp.value) { - const response = await whatsappCallSession.initiateOutboundCall( - conversationId.value - ); + const response = await whatsappCallSession.initiateOutboundCall({ + conversationId: conversationId.value, + }); if (response?.status === VOICE_CALL_OUTBOUND_INIT_STATUS.LOCKED) return; // Permission template path returns no call id — show banner, no widget yet. if (!response?.id) { diff --git a/app/javascript/dashboard/components-next/popover/Popover.vue b/app/javascript/dashboard/components-next/popover/Popover.vue index 5b67e572f..9d369133f 100644 --- a/app/javascript/dashboard/components-next/popover/Popover.vue +++ b/app/javascript/dashboard/components-next/popover/Popover.vue @@ -1,7 +1,11 @@ + + diff --git a/app/javascript/dashboard/routes/dashboard/calls/routes.js b/app/javascript/dashboard/routes/dashboard/calls/routes.js new file mode 100644 index 000000000..fa952a749 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/calls/routes.js @@ -0,0 +1,22 @@ +import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes'; +import { + CONVERSATION_PERMISSIONS, + ROLES, +} from 'dashboard/constants/permissions'; +import { frontendURL } from '../../../helper/URLHelper'; +import CallsIndex from './pages/CallsIndex.vue'; + +export const routes = [ + { + path: frontendURL('accounts/:accountId/calls'), + name: 'calls_dashboard_index', + component: CallsIndex, + meta: { + permissions: [...ROLES, ...CONVERSATION_PERMISSIONS], + installationTypes: [ + INSTALLATION_TYPES.CLOUD, + INSTALLATION_TYPES.ENTERPRISE, + ], + }, + }, +]; diff --git a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue index 29411e56d..ba31bc05d 100644 --- a/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/captain/assistants/overview/Index.vue @@ -1,5 +1,5 @@