chore: voice call campaigns

This commit is contained in:
Sojan
2025-05-05 23:50:34 -07:00
parent 414daff4f1
commit 74cd639574
22 changed files with 803 additions and 16 deletions

View File

@@ -8,6 +8,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue'; import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
import SMSCampaignDetails from './SMSCampaignDetails.vue'; import SMSCampaignDetails from './SMSCampaignDetails.vue';
import VoiceCampaignDetails from './VoiceCampaignDetails.vue';
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -22,6 +23,10 @@ const props = defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isVoiceType: {
type: Boolean,
default: false,
},
isEnabled: { isEnabled: {
type: Boolean, type: Boolean,
default: false, default: false,
@@ -67,6 +72,12 @@ const campaignStatus = computed(() => {
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED') ? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED'); : t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
} }
if (props.isVoiceType) {
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.VOICE.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.VOICE.CARD.STATUS.SCHEDULED');
}
return props.status === STATUS_COMPLETED return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED') ? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
@@ -108,6 +119,12 @@ const inboxIcon = computed(() => {
:inbox-name="inboxName" :inbox-name="inboxName"
:inbox-icon="inboxIcon" :inbox-icon="inboxIcon"
/> />
<VoiceCampaignDetails
v-else-if="isVoiceType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails <SMSCampaignDetails
v-else v-else
:inbox-name="inboxName" :inbox-name="inboxName"
@@ -118,7 +135,7 @@ const inboxIcon = computed(() => {
</div> </div>
<div class="flex items-center justify-end w-20 gap-2"> <div class="flex items-center justify-end w-20 gap-2">
<Button <Button
v-if="isLiveChatType" v-if="isLiveChatType || isVoiceType"
variant="faded" variant="faded"
size="sm" size="sm"
color="slate" color="slate"

View File

@@ -0,0 +1,63 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { getInboxIconByType } from 'dashboard/helper/inbox';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
const props = defineProps({
sender: {
type: Object,
default: null,
},
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
campaign: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
const senderName = computed(() =>
props.sender?.name || t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.BOT')
);
const senderThumbnail = computed(() => props.sender?.thumbnail || '');
</script>
<template>
<div class="flex items-center gap-2 w-full overflow-hidden">
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Avatar
:name="senderName"
:src="senderThumbnail"
:size="16"
rounded-full
/>
<span class="text-sm font-medium text-n-slate-12">
{{ senderName }}
</span>
</div>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.VOICE.CARD.CAMPAIGN_DETAILS.FROM') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Icon :icon="inboxIcon" class="flex-shrink-0 text-n-slate-12 size-3" />
<span class="text-sm font-medium text-n-slate-12">
{{ inboxName }}
</span>
</div>
</div>
</template>

View File

@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
id: 1, id: 1,
name: 'Jamie Lee', name: 'Jamie Lee',
}, },
message: 'Hello! 👋 Any questions on pricing? Im here to help!', message: 'Hello! 👋 Any questions on pricing? I am here to help!',
campaign_status: 'active', campaign_status: 'active',
enabled: false, enabled: false,
campaign_type: 'ongoing', campaign_type: 'ongoing',
@@ -60,7 +60,8 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
}, },
sender: { sender: {
id: 1, id: 1,
name: 'Chatwoot', name: 'Alexa Rivera',
thumbnail: 'AR',
}, },
message: 'Hi! Chatwoot here. Need help setting up? Let me know!', message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
campaign_status: 'active', campaign_status: 'active',
@@ -88,7 +89,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
name: 'Chris Barlow', name: 'Chris Barlow',
}, },
message: message:
'Hi there! 👋 Im here for any questions you may have. Lets chat!', 'Hi there! 👋 I am here for any questions you may have. Let us chat!',
campaign_status: 'active', campaign_status: 'active',
enabled: true, enabled: true,
campaign_type: 'ongoing', campaign_type: 'ongoing',
@@ -166,7 +167,7 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
phone_number: '+29818373149903', phone_number: '+29818373149903',
provider: 'default', provider: 'default',
}, },
message: 'Hello! Were excited to have your business with us!', message: 'Hello! We are excited to have your business with us!',
campaign_status: 'active', campaign_status: 'active',
enabled: true, enabled: true,
campaign_type: 'one_off', campaign_type: 'one_off',
@@ -210,3 +211,114 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
updated_at: '2024-10-30T16:15:03.157Z', updated_at: '2024-10-30T16:15:03.157Z',
}, },
]; ];
export const VOICE_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Signup Confirmation Call',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hello! 👋 Thanks for signing up with PaperLayer. I am calling to confirm your account setup and see if you have any questions about getting started.',
campaign_status: 'scheduled',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-16T20:43:08.000Z').getTime(),
audience: [
{ id: 4, type: 'Label', title: 'Support Customers' },
{ id: 5, type: 'Label', title: 'Active Users' },
],
sender: {
id: 1,
name: 'Chris Barlow',
thumbnail: 'CB'
},
created_at: '2024-11-15T13:13:08.496Z',
updated_at: '2024-11-15T13:15:38.698Z',
},
{
id: 3,
title: 'Support Ticket Follow-Up',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hi, this is PaperLayer support calling to follow up on your recent ticket #12345. Has your issue been resolved to your satisfaction? If not, I can connect you with a specialist right away.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-10T15:30:00.000Z').getTime(),
audience: [
{ id: 1, type: 'Label', title: 'Enterprise' },
{ id: 6, type: 'Label', title: 'Premium' },
],
sender: {
id: 2,
name: 'Sarah Wilson',
thumbnail: 'SW'
},
created_at: '2024-11-10T13:14:00.168Z',
updated_at: '2024-11-10T13:15:38.707Z',
},
{
id: 2,
title: 'Appointment Reminder',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hello, this is a reminder about your upcoming consultation scheduled for tomorrow at 2:00 PM. Would you like to confirm this appointment or would you prefer to reschedule?',
campaign_status: 'scheduled',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-20T18:00:00.000Z').getTime(),
audience: [
{ id: 7, type: 'Label', title: 'Consultation Clients' },
{ id: 8, type: 'Label', title: 'New Customers' },
],
sender: {
id: 3,
name: 'Michael Thompson',
thumbnail: 'MT'
},
created_at: '2024-11-12T09:30:45.123Z',
updated_at: '2024-11-12T09:30:45.123Z',
},
{
id: 4,
title: 'Customer Feedback Survey',
inbox: {
id: 10,
name: 'PaperLayer Phone Support',
channel_type: 'Channel::Voice',
avatar_url: '',
phone_number: '+14155552671',
},
message: 'Hello, this is PaperLayer reaching out for your valuable feedback. We noticed you\'ve been using our service for 30 days now. I\'d like to ask a few quick questions about your experience. Your feedback helps us improve our service. Would you have a moment to share your thoughts?',
campaign_status: 'scheduled',
enabled: true,
campaign_type: 'voice',
scheduled_at: new Date('2024-11-25T14:15:00.000Z').getTime(),
audience: [
{ id: 9, type: 'Label', title: 'Active 30+ Days' },
{ id: 10, type: 'Label', title: 'Product Users' },
],
sender: {
id: 4,
name: 'Jessica Rivera',
thumbnail: 'JR'
},
created_at: '2024-11-18T10:45:23.789Z',
updated_at: '2024-11-18T10:45:23.789Z',
}
];

View File

@@ -0,0 +1,55 @@
<script setup>
import Policy from 'dashboard/components/policy.vue';
defineProps({
title: {
type: String,
required: true,
},
subtitle: {
type: String,
required: true,
},
actionPerms: {
type: Array,
default: () => [],
},
});
</script>
<template>
<section
class="relative flex flex-col items-center justify-center w-full h-full overflow-hidden"
>
<div
class="relative w-full max-w-[60rem] mx-auto overflow-hidden h-full max-h-[36rem]"
>
<div
class="w-full h-full space-y-4 overflow-y-hidden opacity-50 pointer-events-none"
>
<slot name="empty-state-item" />
</div>
<div
class="absolute inset-x-0 bottom-0 flex flex-col items-center justify-end w-full h-full bg-gradient-to-t from-n-background from-0% via-n-background/95 via-25% to-transparent"
>
<div class="flex flex-col items-center justify-center gap-6 w-full max-w-4xl mx-auto px-4">
<div class="flex flex-col items-center justify-center gap-3">
<h2
class="text-3xl font-medium text-center text-slate-900 dark:text-white font-interDisplay"
>
{{ title }}
</h2>
<p
class="max-w-2xl mx-auto text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>
</div>
<Policy :permissions="actionPerms">
<slot name="actions" />
</Policy>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,63 @@
<script setup>
import { VOICE_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import { useI18n } from 'vue-i18n';
import EmptyStateLayout from './CustomEmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
const { t } = useI18n();
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<div
v-for="(campaign, index) in VOICE_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:style="{
opacity: index === 0 ? 1 : index === 1 ? 0.7 : index === 2 ? 0.4 : 0.2
}"
>
<CampaignCard
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-voice-type="true"
/>
</div>
</div>
</template>
<template #actions>
<div class="mt-10 py-5 px-8 rounded-lg bg-slate-800/5 border border-slate-300/10 max-w-[32rem] mx-auto text-center shadow-sm">
<p class="mb-4 text-sm text-slate-700 dark:text-slate-300 font-medium">
{{ t('CAMPAIGN.VOICE.EMPTY_STATE.JS_API_DESCRIPTION') }}
</p>
<code class="block px-5 py-4 overflow-auto text-xs rounded bg-slate-800 text-slate-200 text-left">
chatwoot.triggerVoiceCampaign({
campaignId: 'CAMPAIGN_ID',
user: {
name: 'John Doe',
phone: '+1234567890'
}
});
</code>
</div>
</template>
</EmptyStateLayout>
</template>

View File

@@ -10,6 +10,10 @@ defineProps({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isVoiceType: {
type: Boolean,
default: false,
},
}); });
const emit = defineEmits(['edit', 'delete']); const emit = defineEmits(['edit', 'delete']);
@@ -31,6 +35,7 @@ const handleDelete = campaign => emit('delete', campaign);
:inbox="campaign.inbox" :inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at" :scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType" :is-live-chat-type="isLiveChatType"
:is-voice-type="isVoiceType"
@edit="handleEdit(campaign)" @edit="handleEdit(campaign)"
@delete="handleDelete(campaign)" @delete="handleDelete(campaign)"
/> />

View File

@@ -0,0 +1,60 @@
<script setup>
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
import { useAlert, useTrack } from 'dashboard/composables';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import { CAMPAIGNS_EVENTS } from 'dashboard/helper/AnalyticsHelper/events.js';
import VoiceCampaignForm from './VoiceCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
onMounted(() => {
store.dispatch('inboxes/get');
store.dispatch('labels/get');
});
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', {
...campaignDetails,
campaign_type: CAMPAIGN_TYPES.VOICE,
});
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.VOICE,
});
useAlert(t('CAMPAIGN.VOICE.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage = error?.response?.message || t('CAMPAIGN.VOICE.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleClose = () => emit('close');
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
handleClose();
};
</script>
<template>
<div
class="w-[25rem] z-50 min-w-0 absolute top-10 ltr:right-0 rtl:left-0 bg-n-alpha-3 backdrop-blur-[100px] p-6 rounded-xl border border-slate-50 dark:border-slate-900 shadow-md flex flex-col gap-6 max-h-[85vh] overflow-y-auto"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.VOICE.CREATE.TITLE`) }}
</h3>
<VoiceCampaignForm
@submit="handleSubmit"
@cancel="handleClose"
/>
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getVoiceInboxes'),
};
const initialState = {
title: '',
message: '',
inboxId: null,
scheduledAt: null,
selectedAudience: [],
};
const state = reactive({ ...initialState });
const rules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
scheduledAt: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(rules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const currentDateTime = computed(() => {
// Added to disable the scheduled at field from being set to the current time
const now = new Date();
const localTime = new Date(now.getTime() - now.getTimezoneOffset() * 60000);
return localTime.toISOString().slice(0, 16);
});
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const audienceList = computed(() =>
mapToOptions(formState.labels.value, 'id', 'title')
);
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.VOICE.CREATE.FORM';
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title', 'TITLE'),
message: getErrorMessage('message', 'MESSAGE'),
inbox: getErrorMessage('inboxId', 'INBOX'),
scheduledAt: getErrorMessage('scheduledAt', 'SCHEDULED_AT'),
audience: getErrorMessage('selectedAudience', 'AUDIENCE'),
}));
const isSubmitDisabled = computed(() => v$.value.$invalid);
const formatToUTCString = localDateTime =>
localDateTime ? new Date(localDateTime).toISOString() : null;
const resetState = () => {
Object.assign(state, initialState);
};
const handleCancel = () => emit('cancel');
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
scheduled_at: formatToUTCString(state.scheduledAt),
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
resetState();
handleCancel();
};
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.message"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.MESSAGE.PLACEHOLDER')"
show-character-count
:message="formErrors.message"
:message-type="formErrors.message ? 'error' : 'info'"
/>
<div class="flex flex-col gap-1">
<label for="inbox" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.INBOX.PLACEHOLDER')"
:message="formErrors.inbox"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
</div>
<div class="flex flex-col gap-1">
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.AUDIENCE.PLACEHOLDER')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<Input
v-model="state.scheduledAt"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.VOICE.CREATE.FORM.SCHEDULED_AT.PLACEHOLDER')"
:message="formErrors.scheduledAt"
:message-type="formErrors.scheduledAt ? 'error' : 'info'"
/>
<div class="flex items-center justify-between w-full gap-3">
<Button
variant="faded"
color="slate"
type="button"
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
:label="t('CAMPAIGN.VOICE.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>

View File

@@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
const props = defineProps({ const props = defineProps({
attachedFiles: { type: Array, default: () => [] }, attachedFiles: { type: Array, default: () => [] },
isWhatsappInbox: { type: Boolean, default: false }, isWhatsappInbox: { type: Boolean, default: false },
isVoiceInbox: { type: Boolean, default: false },
isEmailOrWebWidgetInbox: { type: Boolean, default: false }, isEmailOrWebWidgetInbox: { type: Boolean, default: false },
isTwilioSmsInbox: { type: Boolean, default: false }, isTwilioSmsInbox: { type: Boolean, default: false },
messageTemplates: { type: Array, default: () => [] }, messageTemplates: { type: Array, default: () => [] },
@@ -113,6 +114,12 @@ const { onFileUpload } = useFileUpload({
}); });
const sendButtonLabel = computed(() => { const sendButtonLabel = computed(() => {
// For voice inboxes, show "Make a Call" instead of "Send"
if (props.isVoiceInbox) {
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.MAKE_CALL');
}
// For all other inboxes, show the standard "Send" label
const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵'; const keyCode = isEditorHotKeyEnabled('cmd_enter') ? '⌘ + ↵' : '↵';
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', { return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
keyCode, keyCode,
@@ -157,7 +164,7 @@ useKeyboardEvents(keyboardEvents);
@send-message="emit('sendWhatsappMessage', $event)" @send-message="emit('sendWhatsappMessage', $event)"
/> />
<div <div
v-if="!isWhatsappInbox && !hasNoInbox" v-if="!isWhatsappInbox && !isVoiceInbox && !hasNoInbox"
v-on-click-outside="() => (isEmojiPickerOpen = false)" v-on-click-outside="() => (isEmojiPickerOpen = false)"
class="relative" class="relative"
> >
@@ -197,7 +204,7 @@ useKeyboardEvents(keyboardEvents);
/> />
</FileUpload> </FileUpload>
<Button <Button
v-if="hasSelectedInbox && !isWhatsappInbox" v-if="hasSelectedInbox && !isWhatsappInbox && !isVoiceInbox"
icon="i-lucide-signature" icon="i-lucide-signature"
color="slate" color="slate"
size="sm" size="sm"
@@ -217,6 +224,7 @@ useKeyboardEvents(keyboardEvents);
/> />
<Button <Button
v-if="!isWhatsappInbox" v-if="!isWhatsappInbox"
:icon="isVoiceInbox ? 'i-ri-phone-fill' : undefined"
:label="sendButtonLabel" :label="sendButtonLabel"
size="sm" size="sm"
class="!text-xs font-medium" class="!text-xs font-medium"

View File

@@ -68,6 +68,7 @@ const inboxTypes = computed(() => ({
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP, isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB, isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
isApi: props.targetInbox?.channelType === INBOX_TYPES.API, isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
isVoice: props.targetInbox?.channelType === INBOX_TYPES.VOICE,
isEmailOrWebWidget: isEmailOrWebWidget:
props.targetInbox?.channelType === INBOX_TYPES.EMAIL || props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
props.targetInbox?.channelType === INBOX_TYPES.WEB, props.targetInbox?.channelType === INBOX_TYPES.WEB,
@@ -87,7 +88,7 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
const validationRules = computed(() => ({ const validationRules = computed(() => ({
selectedContact: { required }, selectedContact: { required },
targetInbox: { required }, targetInbox: { required },
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) }, message: { required: requiredIf(!inboxTypes.value.isWhatsapp && !inboxTypes.value.isVoice) },
subject: { required: requiredIf(inboxTypes.value.isEmail) }, subject: { required: requiredIf(inboxTypes.value.isEmail) },
})); }));
@@ -311,7 +312,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/> />
<MessageEditor <MessageEditor
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert" v-if="!inboxTypes.isWhatsapp && !inboxTypes.isVoice && !showNoInboxAlert"
v-model="state.message" v-model="state.message"
:message-signature="messageSignature" :message-signature="messageSignature"
:send-with-signature="sendWithSignature" :send-with-signature="sendWithSignature"
@@ -321,7 +322,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
/> />
<AttachmentPreviews <AttachmentPreviews
v-if="state.attachedFiles.length > 0" v-if="state.attachedFiles.length > 0 && !inboxTypes.isVoice"
:attachments="state.attachedFiles" :attachments="state.attachedFiles"
@update:attachments="state.attachedFiles = $event" @update:attachments="state.attachedFiles = $event"
/> />
@@ -329,6 +330,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
<ActionButtons <ActionButtons
:attached-files="state.attachedFiles" :attached-files="state.attachedFiles"
:is-whatsapp-inbox="inboxTypes.isWhatsapp" :is-whatsapp-inbox="inboxTypes.isWhatsapp"
:is-voice-inbox="inboxTypes.isVoice"
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS" :is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
:message-templates="whatsappMessageTemplates" :message-templates="whatsappMessageTemplates"

View File

@@ -8,8 +8,9 @@ const CHANNEL_PRIORITY = {
'Channel::Whatsapp': 2, 'Channel::Whatsapp': 2,
'Channel::Sms': 3, 'Channel::Sms': 3,
'Channel::TwilioSms': 4, 'Channel::TwilioSms': 4,
'Channel::WebWidget': 5, 'Channel::Voice': 5,
'Channel::Api': 6, 'Channel::WebWidget': 6,
'Channel::Api': 7,
}; };
export const generateLabelForContactableInboxesList = ({ export const generateLabelForContactableInboxesList = ({
@@ -23,7 +24,8 @@ export const generateLabelForContactableInboxesList = ({
} }
if ( if (
channelType === INBOX_TYPES.TWILIO || channelType === INBOX_TYPES.TWILIO ||
channelType === INBOX_TYPES.WHATSAPP channelType === INBOX_TYPES.WHATSAPP ||
channelType === INBOX_TYPES.VOICE
) { ) {
return `${name} (${phoneNumber})`; return `${name} (${phoneNumber})`;
} }

View File

@@ -325,6 +325,11 @@ const menuItems = computed(() => {
label: t('SIDEBAR.SMS'), label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'), to: accountScopedRoute('campaigns_sms_index'),
}, },
{
name: 'Voice',
label: t('SIDEBAR.VOICE'),
to: accountScopedRoute('campaigns_voice_index'),
},
], ],
}, },
{ {

View File

@@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables'; import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import VoiceAPI from 'dashboard/api/channels/voice'; import VoiceAPI from 'dashboard/api/channels/voice';
import ContactAPI from 'dashboard/api/contacts'; import ContactAPI from 'dashboard/api/contacts';
import DashboardAudioNotificationHelper from 'dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper'; import DashboardAudioNotificationHelper from 'dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper';
@@ -322,7 +323,7 @@ export default {
}, 300); }, 300);
}; };
// Accept incoming call // Accept incoming call and redirect to conversation
const acceptCall = async () => { const acceptCall = async () => {
console.log('Accepting incoming call with SID:', incomingCall.value?.callSid); console.log('Accepting incoming call with SID:', incomingCall.value?.callSid);
@@ -380,6 +381,19 @@ export default {
// Emit event // Emit event
emit('callJoined'); emit('callJoined');
// IMPORTANT: Redirect to the conversation view
if (conversationId) {
const accountId = this.$route.params.accountId;
const path = frontendURL(
conversationUrl({
accountId,
id: conversationId,
})
);
console.log(`Redirecting to conversation path: ${path}`);
this.$router.push({ path });
}
} else { } else {
throw new Error('Failed to join call via WebRTC or phone'); throw new Error('Failed to join call via WebRTC or phone');
} }

View File

@@ -1,5 +1,9 @@
{ {
"CAMPAIGN": { "CAMPAIGN": {
"BADGE": {
"ACTIVE": "Active",
"COMPLETED": "Completed"
},
"LIVE_CHAT": { "LIVE_CHAT": {
"HEADER_TITLE": "Live chat campaigns", "HEADER_TITLE": "Live chat campaigns",
"NEW_CAMPAIGN": "Create campaign", "NEW_CAMPAIGN": "Create campaign",
@@ -137,6 +141,80 @@
} }
} }
}, },
"VOICE": {
"HEADER_TITLE": "Voice campaigns",
"NEW_CAMPAIGN": "Create campaign",
"EMPTY_STATE": {
"TITLE": "No voice campaigns are available",
"SUBTITLE": "Make customer outreach more personal with AI-powered voice calls. Perfect for appointment reminders, verifications, and follow-ups that feel natural. Your AI assistant can adapt to customer responses, gather information, and enrich leads—saving your team valuable time.",
"JS_API_DESCRIPTION": "You can trigger voice campaigns programmatically using our JavaScript API:"
},
"CARD": {
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
"CAMPAIGN_DETAILS": {
"SENT_BY": "Sent by ",
"FROM": " from ",
"ON": " on ",
"BOT": "Bot"
}
},
"EMPTY_STATE_BADGE": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"DETAILS": {
"MESSAGE": "Message",
"INBOX": "Inbox",
"AUDIENCE": "Audience",
"SCHEDULED_AT": "Scheduled at"
},
"CREATE": {
"TITLE": "Create voice campaign",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter the title of your voice campaign",
"ERROR": "Title is required"
},
"MESSAGE": {
"LABEL": "Call Script",
"PLACEHOLDER": "Add the prompt to guide the AI during this call. Be specific about tone, information to gather, and how to handle different responses.",
"ERROR": "Call script is required"
},
"INBOX": {
"LABEL": "Voice Inbox",
"PLACEHOLDER": "Select voice inbox",
"ERROR": "Voice inbox is required"
},
"SENT_BY": {
"LABEL": "Sent by",
"PLACEHOLDER": "Select sender",
"ERROR": "Sender is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select audience labels",
"ERROR": "Audience is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Select when to start the campaign",
"ERROR": "Scheduled time is required"
},
"BUTTONS": {
"CREATE": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Voice campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
}
},
"CONFIRM_DELETE": { "CONFIRM_DELETE": {
"TITLE": "Are you sure to delete?", "TITLE": "Are you sure to delete?",
"DESCRIPTION": "The delete action is permanent and cannot be reversed.", "DESCRIPTION": "The delete action is permanent and cannot be reversed.",
@@ -147,4 +225,4 @@
} }
} }
} }
} }

View File

@@ -603,7 +603,8 @@
}, },
"ACTION_BUTTONS": { "ACTION_BUTTONS": {
"DISCARD": "Discard", "DISCARD": "Discard",
"SEND": "Send ({keyCode})" "SEND": "Send ({keyCode})",
"MAKE_CALL": "Make a Call"
} }
} }
} }

View File

@@ -312,6 +312,7 @@
"CSAT": "CSAT", "CSAT": "CSAT",
"LIVE_CHAT": "Live Chat", "LIVE_CHAT": "Live Chat",
"SMS": "SMS", "SMS": "SMS",
"VOICE": "Voice",
"CAMPAIGNS": "Campaigns", "CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing", "ONGOING": "Ongoing",
"ONE_OFF": "One off", "ONE_OFF": "One off",

View File

@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue'; import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue'; import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue'; import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import VoiceCampaignsPage from './pages/VoiceCampaignsPage.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = { const meta = {
@@ -50,6 +51,12 @@ const campaignsRoutes = {
meta, meta,
component: SMSCampaignsPage, component: SMSCampaignsPage,
}, },
{
path: 'voice',
name: 'campaigns_voice_index',
meta,
component: VoiceCampaignsPage,
},
], ],
}, },
], ],

View File

@@ -0,0 +1,76 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useToggle } from '@vueuse/core';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { CAMPAIGN_TYPES } from 'shared/constants/campaign.js';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import CampaignLayout from 'dashboard/components-next/Campaigns/CampaignLayout.vue';
import CampaignList from 'dashboard/components-next/Campaigns/Pages/CampaignPage/CampaignList.vue';
import VoiceCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/VoiceCampaign/VoiceCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import VoiceCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/VoiceCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showVoiceCampaignDialog, toggleVoiceCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const voiceCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.VOICE)
);
const hasNoVoiceCampaigns = computed(
() => voiceCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.VOICE.HEADER_TITLE')"
:button-label="t('CAMPAIGN.VOICE.NEW_CAMPAIGN')"
@click="toggleVoiceCampaignDialog()"
@close="toggleVoiceCampaignDialog(false)"
>
<template #action>
<VoiceCampaignDialog
v-if="showVoiceCampaignDialog"
@close="toggleVoiceCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoVoiceCampaigns"
:campaigns="voiceCampaigns"
:is-voice-type="true"
@delete="handleDelete"
/>
<VoiceCampaignEmptyState
v-else
:title="t('CAMPAIGN.VOICE.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.VOICE.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -21,6 +21,7 @@ import {
getConversationDashboardRoute, getConversationDashboardRoute,
} from '../../../../helper/routeHelpers'; } from '../../../../helper/routeHelpers';
import { emitter } from 'shared/helpers/mitt'; import { emitter } from 'shared/helpers/mitt';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
export default { export default {
components: { components: {
@@ -213,6 +214,19 @@ export default {
window.app.$data.showCallWidget = true; window.app.$data.showCallWidget = true;
} }
// IMPORTANT: Redirect to the conversation view
if (conversation.id) {
const accountId = this.$route.params.accountId;
const path = frontendURL(
conversationUrl({
accountId,
id: conversation.id,
})
);
console.log(`Redirecting to conversation path: ${path}`);
this.$router.push({ path });
}
// After a brief delay, force update UI // After a brief delay, force update UI
setTimeout(() => { setTimeout(() => {
this.$forceUpdate(); this.$forceUpdate();

View File

@@ -117,6 +117,12 @@ export const getters = {
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms') (item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
); );
}, },
getVoiceInboxes($state) {
return $state.records.filter(
item =>
item.channel_type === INBOX_TYPES.VOICE
);
},
dialogFlowEnabledInboxes($state) { dialogFlowEnabledInboxes($state) {
return $state.records.filter( return $state.records.filter(
item => item.channel_type !== INBOX_TYPES.EMAIL item => item.channel_type !== INBOX_TYPES.EMAIL

View File

@@ -1,4 +1,5 @@
export const CAMPAIGN_TYPES = { export const CAMPAIGN_TYPES = {
ONGOING: 'ongoing', ONGOING: 'ongoing',
ONE_OFF: 'one_off', ONE_OFF: 'one_off',
VOICE: 'voice',
}; };

View File

@@ -22,6 +22,8 @@ class Contacts::ContactableInboxesService
api_contactable_inbox(inbox) api_contactable_inbox(inbox)
when 'Channel::WebWidget' when 'Channel::WebWidget'
website_contactable_inbox(inbox) website_contactable_inbox(inbox)
when 'Channel::Voice'
voice_contactable_inbox(inbox)
end end
end end
@@ -70,4 +72,10 @@ class Contacts::ContactableInboxesService
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox } { source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
end end
end end
def voice_contactable_inbox(inbox)
return if @contact.phone_number.blank?
{ source_id: @contact.phone_number, inbox: inbox }
end
end end