mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-04 11:55:24 +00:00
chore: voice call campaigns
This commit is contained in:
@@ -8,6 +8,7 @@ import CardLayout from 'dashboard/components-next/CardLayout.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import LiveChatCampaignDetails from './LiveChatCampaignDetails.vue';
|
||||
import SMSCampaignDetails from './SMSCampaignDetails.vue';
|
||||
import VoiceCampaignDetails from './VoiceCampaignDetails.vue';
|
||||
|
||||
const props = defineProps({
|
||||
title: {
|
||||
@@ -22,6 +23,10 @@ const props = defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isVoiceType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isEnabled: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -68,6 +73,12 @@ const campaignStatus = computed(() => {
|
||||
: 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
|
||||
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
|
||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||
@@ -108,6 +119,12 @@ const inboxIcon = computed(() => {
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<VoiceCampaignDetails
|
||||
v-else-if="isVoiceType"
|
||||
:sender="sender"
|
||||
:inbox-name="inboxName"
|
||||
:inbox-icon="inboxIcon"
|
||||
/>
|
||||
<SMSCampaignDetails
|
||||
v-else
|
||||
:inbox-name="inboxName"
|
||||
@@ -118,7 +135,7 @@ const inboxIcon = computed(() => {
|
||||
</div>
|
||||
<div class="flex items-center justify-end w-20 gap-2">
|
||||
<Button
|
||||
v-if="isLiveChatType"
|
||||
v-if="isLiveChatType || isVoiceType"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
color="slate"
|
||||
|
||||
@@ -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>
|
||||
@@ -37,7 +37,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
id: 1,
|
||||
name: 'Jamie Lee',
|
||||
},
|
||||
message: 'Hello! 👋 Any questions on pricing? I’m here to help!',
|
||||
message: 'Hello! 👋 Any questions on pricing? I am here to help!',
|
||||
campaign_status: 'active',
|
||||
enabled: false,
|
||||
campaign_type: 'ongoing',
|
||||
@@ -60,7 +60,8 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'Chatwoot',
|
||||
name: 'Alexa Rivera',
|
||||
thumbnail: 'AR',
|
||||
},
|
||||
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
|
||||
campaign_status: 'active',
|
||||
@@ -88,7 +89,7 @@ export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
name: 'Chris Barlow',
|
||||
},
|
||||
message:
|
||||
'Hi there! 👋 I’m here for any questions you may have. Let’s chat!',
|
||||
'Hi there! 👋 I am here for any questions you may have. Let us chat!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'ongoing',
|
||||
@@ -166,7 +167,7 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
phone_number: '+29818373149903',
|
||||
provider: 'default',
|
||||
},
|
||||
message: 'Hello! We’re excited to have your business with us!',
|
||||
message: 'Hello! We are excited to have your business with us!',
|
||||
campaign_status: 'active',
|
||||
enabled: true,
|
||||
campaign_type: 'one_off',
|
||||
@@ -210,3 +211,114 @@ export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
|
||||
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',
|
||||
}
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -10,6 +10,10 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isVoiceType: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['edit', 'delete']);
|
||||
@@ -31,6 +35,7 @@ const handleDelete = campaign => emit('delete', campaign);
|
||||
:inbox="campaign.inbox"
|
||||
:scheduled-at="campaign.scheduled_at"
|
||||
:is-live-chat-type="isLiveChatType"
|
||||
:is-voice-type="isVoiceType"
|
||||
@edit="handleEdit(campaign)"
|
||||
@delete="handleDelete(campaign)"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -15,6 +15,7 @@ import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isVoiceInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
@@ -113,6 +114,12 @@ const { onFileUpload } = useFileUpload({
|
||||
});
|
||||
|
||||
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') ? '⌘ + ↵' : '↵';
|
||||
return t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.SEND', {
|
||||
keyCode,
|
||||
@@ -157,7 +164,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-if="!isWhatsappInbox && !isVoiceInbox && !hasNoInbox"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -197,7 +204,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox && !isVoiceInbox"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -217,6 +224,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<Button
|
||||
v-if="!isWhatsappInbox"
|
||||
:icon="isVoiceInbox ? 'i-ri-phone-fill' : undefined"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
@@ -68,6 +68,7 @@ const inboxTypes = computed(() => ({
|
||||
isWhatsapp: props.targetInbox?.channelType === INBOX_TYPES.WHATSAPP,
|
||||
isWebWidget: props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
isApi: props.targetInbox?.channelType === INBOX_TYPES.API,
|
||||
isVoice: props.targetInbox?.channelType === INBOX_TYPES.VOICE,
|
||||
isEmailOrWebWidget:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.EMAIL ||
|
||||
props.targetInbox?.channelType === INBOX_TYPES.WEB,
|
||||
@@ -87,7 +88,7 @@ const inboxChannelType = computed(() => props.targetInbox?.channelType || '');
|
||||
const validationRules = computed(() => ({
|
||||
selectedContact: { required },
|
||||
targetInbox: { required },
|
||||
message: { required: requiredIf(!inboxTypes.value.isWhatsapp) },
|
||||
message: { required: requiredIf(!inboxTypes.value.isWhatsapp && !inboxTypes.value.isVoice) },
|
||||
subject: { required: requiredIf(inboxTypes.value.isEmail) },
|
||||
}));
|
||||
|
||||
@@ -311,7 +312,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-if="!inboxTypes.isWhatsapp && !inboxTypes.isVoice && !showNoInboxAlert"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -321,7 +322,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<AttachmentPreviews
|
||||
v-if="state.attachedFiles.length > 0"
|
||||
v-if="state.attachedFiles.length > 0 && !inboxTypes.isVoice"
|
||||
:attachments="state.attachedFiles"
|
||||
@update:attachments="state.attachedFiles = $event"
|
||||
/>
|
||||
@@ -329,6 +330,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
<ActionButtons
|
||||
:attached-files="state.attachedFiles"
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-voice-inbox="inboxTypes.isVoice"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
|
||||
@@ -8,8 +8,9 @@ const CHANNEL_PRIORITY = {
|
||||
'Channel::Whatsapp': 2,
|
||||
'Channel::Sms': 3,
|
||||
'Channel::TwilioSms': 4,
|
||||
'Channel::WebWidget': 5,
|
||||
'Channel::Api': 6,
|
||||
'Channel::Voice': 5,
|
||||
'Channel::WebWidget': 6,
|
||||
'Channel::Api': 7,
|
||||
};
|
||||
|
||||
export const generateLabelForContactableInboxesList = ({
|
||||
@@ -23,7 +24,8 @@ export const generateLabelForContactableInboxesList = ({
|
||||
}
|
||||
if (
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
channelType === INBOX_TYPES.WHATSAPP ||
|
||||
channelType === INBOX_TYPES.VOICE
|
||||
) {
|
||||
return `${name} (${phoneNumber})`;
|
||||
}
|
||||
|
||||
@@ -325,6 +325,11 @@ const menuItems = computed(() => {
|
||||
label: t('SIDEBAR.SMS'),
|
||||
to: accountScopedRoute('campaigns_sms_index'),
|
||||
},
|
||||
{
|
||||
name: 'Voice',
|
||||
label: t('SIDEBAR.VOICE'),
|
||||
to: accountScopedRoute('campaigns_voice_index'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ import { computed, ref, watch, onMounted, onBeforeUnmount } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import VoiceAPI from 'dashboard/api/channels/voice';
|
||||
import ContactAPI from 'dashboard/api/contacts';
|
||||
import DashboardAudioNotificationHelper from 'dashboard/helper/AudioAlerts/DashboardAudioNotificationHelper';
|
||||
@@ -322,7 +323,7 @@ export default {
|
||||
}, 300);
|
||||
};
|
||||
|
||||
// Accept incoming call
|
||||
// Accept incoming call and redirect to conversation
|
||||
const acceptCall = async () => {
|
||||
console.log('Accepting incoming call with SID:', incomingCall.value?.callSid);
|
||||
|
||||
@@ -380,6 +381,19 @@ export default {
|
||||
|
||||
// Emit event
|
||||
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 {
|
||||
throw new Error('Failed to join call via WebRTC or phone');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"CAMPAIGN": {
|
||||
"BADGE": {
|
||||
"ACTIVE": "Active",
|
||||
"COMPLETED": "Completed"
|
||||
},
|
||||
"LIVE_CHAT": {
|
||||
"HEADER_TITLE": "Live chat campaigns",
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete?",
|
||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
||||
|
||||
@@ -603,7 +603,8 @@
|
||||
},
|
||||
"ACTION_BUTTONS": {
|
||||
"DISCARD": "Discard",
|
||||
"SEND": "Send ({keyCode})"
|
||||
"SEND": "Send ({keyCode})",
|
||||
"MAKE_CALL": "Make a Call"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,6 +312,7 @@
|
||||
"CSAT": "CSAT",
|
||||
"LIVE_CHAT": "Live Chat",
|
||||
"SMS": "SMS",
|
||||
"VOICE": "Voice",
|
||||
"CAMPAIGNS": "Campaigns",
|
||||
"ONGOING": "Ongoing",
|
||||
"ONE_OFF": "One off",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { frontendURL } from 'dashboard/helper/URLHelper.js';
|
||||
import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
|
||||
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||
import VoiceCampaignsPage from './pages/VoiceCampaignsPage.vue';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const meta = {
|
||||
@@ -50,6 +51,12 @@ const campaignsRoutes = {
|
||||
meta,
|
||||
component: SMSCampaignsPage,
|
||||
},
|
||||
{
|
||||
path: 'voice',
|
||||
name: 'campaigns_voice_index',
|
||||
meta,
|
||||
component: VoiceCampaignsPage,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getConversationDashboardRoute,
|
||||
} from '../../../../helper/routeHelpers';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -213,6 +214,19 @@ export default {
|
||||
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
|
||||
setTimeout(() => {
|
||||
this.$forceUpdate();
|
||||
|
||||
@@ -117,6 +117,12 @@ export const getters = {
|
||||
(item.channel_type === INBOX_TYPES.TWILIO && item.medium === 'sms')
|
||||
);
|
||||
},
|
||||
getVoiceInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item =>
|
||||
item.channel_type === INBOX_TYPES.VOICE
|
||||
);
|
||||
},
|
||||
dialogFlowEnabledInboxes($state) {
|
||||
return $state.records.filter(
|
||||
item => item.channel_type !== INBOX_TYPES.EMAIL
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
export const CAMPAIGN_TYPES = {
|
||||
ONGOING: 'ongoing',
|
||||
ONE_OFF: 'one_off',
|
||||
VOICE: 'voice',
|
||||
};
|
||||
|
||||
@@ -22,6 +22,8 @@ class Contacts::ContactableInboxesService
|
||||
api_contactable_inbox(inbox)
|
||||
when 'Channel::WebWidget'
|
||||
website_contactable_inbox(inbox)
|
||||
when 'Channel::Voice'
|
||||
voice_contactable_inbox(inbox)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -70,4 +72,10 @@ class Contacts::ContactableInboxesService
|
||||
{ source_id: "whatsapp:#{@contact.phone_number}", inbox: inbox }
|
||||
end
|
||||
end
|
||||
|
||||
def voice_contactable_inbox(inbox)
|
||||
return if @contact.phone_number.blank?
|
||||
|
||||
{ source_id: @contact.phone_number, inbox: inbox }
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user