mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-04 20:05:22 +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 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,
|
||||||
@@ -68,6 +73,12 @@ const campaignStatus = computed(() => {
|
|||||||
: 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')
|
||||||
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
id: 1,
|
||||||
name: 'Jamie Lee',
|
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',
|
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! 👋 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',
|
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! We’re 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',
|
||||||
|
}
|
||||||
|
];
|
||||||
@@ -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,
|
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)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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({
|
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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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})`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -603,7 +603,8 @@
|
|||||||
},
|
},
|
||||||
"ACTION_BUTTONS": {
|
"ACTION_BUTTONS": {
|
||||||
"DISCARD": "Discard",
|
"DISCARD": "Discard",
|
||||||
"SEND": "Send ({keyCode})"
|
"SEND": "Send ({keyCode})",
|
||||||
|
"MAKE_CALL": "Make a Call"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user