feat(v4): Update the campaigns page design (#10371)

<img width="1439" alt="Screenshot 2024-10-30 at 8 58 12 PM"
src="https://github.com/user-attachments/assets/26231270-5e73-40fb-9efa-c661585ebe7c">


Fixes
https://linear.app/chatwoot/project/campaign-redesign-f82bede26ca7/overview

---------

Co-authored-by: Pranav <pranavrajs@gmail.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Sivin Varghese
2024-10-31 11:57:13 +05:30
committed by GitHub
parent 6e6c5a2f02
commit 579efd933b
59 changed files with 2523 additions and 1458 deletions

View File

@@ -0,0 +1,60 @@
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';
const campaignsRoutes = {
routes: [
{
path: frontendURL('accounts/:accountId/campaigns'),
component: CampaignsPageRouteView,
children: [
{
path: '',
redirect: to => {
return { name: 'campaigns_ongoing_index', params: to.params };
},
},
{
path: 'ongoing',
name: 'campaigns_ongoing_index',
meta: {
permissions: ['administrator'],
},
redirect: to => {
return { name: 'campaigns_livechat_index', params: to.params };
},
},
{
path: 'one_off',
name: 'campaigns_one_off_index',
meta: {
permissions: ['administrator'],
},
redirect: to => {
return { name: 'campaigns_sms_index', params: to.params };
},
},
{
path: 'live_chat',
name: 'campaigns_livechat_index',
meta: {
permissions: ['administrator'],
},
component: LiveChatCampaignsPage,
},
{
path: 'sms',
name: 'campaigns_sms_index',
meta: {
permissions: ['administrator'],
},
component: SMSCampaignsPage,
},
],
},
],
};
export default campaignsRoutes;

View File

@@ -0,0 +1,28 @@
<script setup>
import { onMounted } from 'vue';
import { useStore } from 'dashboard/composables/store';
defineProps({
keepAlive: { type: Boolean, default: true },
});
const store = useStore();
onMounted(() => {
store.dispatch('campaigns/get');
store.dispatch('labels/get');
});
</script>
<template>
<div
class="flex flex-col justify-between flex-1 h-full m-0 overflow-auto bg-n-background"
>
<router-view v-slot="{ Component }">
<keep-alive v-if="keepAlive">
<component :is="Component" />
</keep-alive>
<component :is="Component" v-else />
</router-view>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<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 LiveChatCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignDialog.vue';
import EditLiveChatCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/EditLiveChatCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import LiveChatCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/LiveChatCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const editLiveChatCampaignDialogRef = ref(null);
const confirmDeleteCampaignDialogRef = ref(null);
const selectedCampaign = ref(null);
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const [showLiveChatCampaignDialog, toggleLiveChatCampaignDialog] = useToggle();
const liveChatCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONGOING)
);
const hasNoLiveChatCampaigns = computed(
() => liveChatCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleEdit = campaign => {
selectedCampaign.value = campaign;
editLiveChatCampaignDialogRef.value.dialogRef.open();
};
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.LIVE_CHAT.HEADER_TITLE')"
:button-label="t('CAMPAIGN.LIVE_CHAT.NEW_CAMPAIGN')"
@click="toggleLiveChatCampaignDialog()"
@close="toggleLiveChatCampaignDialog(false)"
>
<template #action>
<LiveChatCampaignDialog
v-if="showLiveChatCampaignDialog"
@close="toggleLiveChatCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoLiveChatCampaigns"
:campaigns="liveChatCampaigns"
is-live-chat-type
@edit="handleEdit"
@delete="handleDelete"
/>
<LiveChatCampaignEmptyState
v-else
:title="t('CAMPAIGN.LIVE_CHAT.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.LIVE_CHAT.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<EditLiveChatCampaignDialog
ref="editLiveChatCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -0,0 +1,75 @@
<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 SMSCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignDialog.vue';
import ConfirmDeleteCampaignDialog from 'dashboard/components-next/Campaigns/Pages/CampaignPage/ConfirmDeleteCampaignDialog.vue';
import SMSCampaignEmptyState from 'dashboard/components-next/Campaigns/EmptyState/SMSCampaignEmptyState.vue';
const { t } = useI18n();
const getters = useStoreGetters();
const selectedCampaign = ref(null);
const [showSMSCampaignDialog, toggleSMSCampaignDialog] = useToggle();
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetchingCampaigns = computed(() => uiFlags.value.isFetching);
const confirmDeleteCampaignDialogRef = ref(null);
const SMSCampaigns = computed(() =>
getters['campaigns/getCampaigns'].value(CAMPAIGN_TYPES.ONE_OFF)
);
const hasNoSMSCampaigns = computed(
() => SMSCampaigns.value?.length === 0 && !isFetchingCampaigns.value
);
const handleDelete = campaign => {
selectedCampaign.value = campaign;
confirmDeleteCampaignDialogRef.value.dialogRef.open();
};
</script>
<template>
<CampaignLayout
:header-title="t('CAMPAIGN.SMS.HEADER_TITLE')"
:button-label="t('CAMPAIGN.SMS.NEW_CAMPAIGN')"
@click="toggleSMSCampaignDialog()"
@close="toggleSMSCampaignDialog(false)"
>
<template #action>
<SMSCampaignDialog
v-if="showSMSCampaignDialog"
@close="toggleSMSCampaignDialog(false)"
/>
</template>
<div
v-if="isFetchingCampaigns"
class="flex items-center justify-center py-10 text-n-slate-11"
>
<Spinner />
</div>
<CampaignList
v-else-if="!hasNoSMSCampaigns"
:campaigns="SMSCampaigns"
@delete="handleDelete"
/>
<SMSCampaignEmptyState
v-else
:title="t('CAMPAIGN.SMS.EMPTY_STATE.TITLE')"
:subtitle="t('CAMPAIGN.SMS.EMPTY_STATE.SUBTITLE')"
class="pt-14"
/>
<ConfirmDeleteCampaignDialog
ref="confirmDeleteCampaignDialogRef"
:selected-campaign="selectedCampaign"
/>
</CampaignLayout>
</template>

View File

@@ -3,8 +3,7 @@ import { ref } from 'vue';
// constants & helpers
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import { getInboxSource } from 'dashboard/helper/inbox';
import { getInboxSource, INBOX_TYPES } from 'dashboard/helper/inbox';
// store
import { mapGetters } from 'vuex';

View File

@@ -6,6 +6,7 @@ import { routes as notificationRoutes } from './notifications/routes';
import { routes as inboxRoutes } from './inbox/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
import campaignsRoutes from './campaigns/campaigns.routes';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
@@ -35,6 +36,7 @@ export default {
...searchRoutes,
...notificationRoutes,
...helpcenterRoutes.routes,
...campaignsRoutes.routes,
],
},
{

View File

@@ -1,389 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert, useTrack } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
import { CAMPAIGNS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
export default {
components: {
WootDateTimePicker,
WootMessageEditor,
},
emits: ['onClose'],
setup() {
const { campaignType, isOngoingType, isOneOffType } = useCampaign();
return { v$: useVuelidate(), campaignType, isOngoingType, isOneOffType };
},
data() {
return {
title: '',
message: '',
selectedSender: 0,
selectedInbox: null,
endPoint: '',
timeOnPage: 10,
show: true,
enabled: true,
triggerOnlyDuringBusinessHours: false,
scheduledAt: null,
selectedAudience: [],
senderList: [],
};
},
validations() {
const commonValidations = {
title: {
required,
},
message: {
required,
},
selectedInbox: {
required,
},
};
if (this.isOngoingType) {
return {
...commonValidations,
selectedSender: {
required,
},
endPoint: {
required,
shouldBeAValidURLPattern(value) {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return (
value.startsWith('https://') || value.startsWith('http://')
);
}
return false;
},
},
timeOnPage: {
required,
},
};
}
return {
...commonValidations,
selectedAudience: {
isEmpty() {
return !!this.selectedAudience.length;
},
},
};
},
computed: {
...mapGetters({
uiFlags: 'campaigns/getUIFlags',
audienceList: 'labels/getLabels',
}),
inboxes() {
if (this.isOngoingType) {
return this.$store.getters['inboxes/getWebsiteInboxes'];
}
return this.$store.getters['inboxes/getSMSInboxes'];
},
sendersAndBotList() {
return [
{
id: 0,
name: 'Bot',
},
...this.senderList,
];
},
},
mounted() {
useTrack(CAMPAIGNS_EVENTS.OPEN_NEW_CAMPAIGN_MODAL, {
type: this.campaignType,
});
},
methods: {
onClose() {
this.$emit('onClose');
},
onChange(value) {
this.scheduledAt = value;
},
async onChangeInbox() {
try {
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.selectedInbox,
});
const {
data: { payload: inboxMembers },
} = response;
this.senderList = inboxMembers;
} catch (error) {
const errorMessage =
error?.response?.message || this.$t('CAMPAIGN.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
},
getCampaignDetails() {
let campaignDetails = null;
if (this.isOngoingType) {
campaignDetails = {
title: this.title,
message: this.message,
inbox_id: this.selectedInbox,
sender_id: this.selectedSender || null,
enabled: this.enabled,
trigger_only_during_business_hours:
// eslint-disable-next-line prettier/prettier
this.triggerOnlyDuringBusinessHours,
trigger_rules: {
url: this.endPoint,
time_on_page: this.timeOnPage,
},
};
} else {
const audience = this.selectedAudience.map(item => {
return {
id: item.id,
type: 'Label',
};
});
campaignDetails = {
title: this.title,
message: this.message,
inbox_id: this.selectedInbox,
scheduled_at: this.scheduledAt,
audience,
};
}
return campaignDetails;
},
async addCampaign() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
try {
const campaignDetails = this.getCampaignDetails();
await this.$store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: this.campaignType,
});
useAlert(this.$t('CAMPAIGN.ADD.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
const errorMessage =
error?.response?.message || this.$t('CAMPAIGN.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
},
},
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CAMPAIGN.ADD.TITLE')"
:header-content="$t('CAMPAIGN.ADD.DESC')"
/>
<form class="flex flex-col w-full" @submit.prevent="addCampaign">
<div class="w-full">
<woot-input
v-model="title"
:label="$t('CAMPAIGN.ADD.FORM.TITLE.LABEL')"
type="text"
:class="{ error: v$.title.$error }"
:error="v$.title.$error ? $t('CAMPAIGN.ADD.FORM.TITLE.ERROR') : ''"
:placeholder="$t('CAMPAIGN.ADD.FORM.TITLE.PLACEHOLDER')"
@blur="v$.title.$touch"
/>
<div v-if="isOngoingType" class="editor-wrap">
<label>
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
</label>
<div>
<WootMessageEditor
v-model="message"
class="message-editor"
:class="{ editor_warning: v$.message.$error }"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@blur="v$.message.$touch"
/>
<span v-if="v$.message.$error" class="editor-warning__message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</div>
</div>
<label v-else :class="{ error: v$.message.$error }">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
<textarea
v-model="message"
rows="5"
type="text"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@blur="v$.message.$touch"
/>
<span v-if="v$.message.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</label>
<label :class="{ error: v$.selectedInbox.$error }">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
<select v-model="selectedInbox" @change="onChangeInbox($event)">
<option v-for="item in inboxes" :key="item.name" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.selectedInbox.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.ERROR') }}
</span>
</label>
<label
v-if="isOneOffType"
class="multiselect-wrap--small"
:class="{ error: v$.selectedAudience.$error }"
>
{{ $t('CAMPAIGN.ADD.FORM.AUDIENCE.LABEL') }}
<multiselect
v-model="selectedAudience"
:options="audienceList"
track-by="id"
label="title"
multiple
:close-on-select="false"
:clear-on-select="false"
hide-selected
:placeholder="$t('CAMPAIGN.ADD.FORM.AUDIENCE.PLACEHOLDER')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
@blur="v$.selectedAudience.$touch"
@select="v$.selectedAudience.$touch"
/>
<span v-if="v$.selectedAudience.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.AUDIENCE.ERROR') }}
</span>
</label>
<label
v-if="isOngoingType"
:class="{ error: v$.selectedSender.$error }"
>
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.LABEL') }}
<select v-model="selectedSender">
<option
v-for="sender in sendersAndBotList"
:key="sender.name"
:value="sender.id"
>
{{ sender.name }}
</option>
</select>
<span v-if="v$.selectedSender.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.ERROR') }}
</span>
</label>
<label v-if="isOneOffType">
{{ $t('CAMPAIGN.ADD.FORM.SCHEDULED_AT.LABEL') }}
<WootDateTimePicker
:value="scheduledAt"
:confirm-text="$t('CAMPAIGN.ADD.FORM.SCHEDULED_AT.CONFIRM')"
:placeholder="$t('CAMPAIGN.ADD.FORM.SCHEDULED_AT.PLACEHOLDER')"
@change="onChange"
/>
</label>
<woot-input
v-if="isOngoingType"
v-model="endPoint"
:label="$t('CAMPAIGN.ADD.FORM.END_POINT.LABEL')"
type="text"
:class="{ error: v$.endPoint.$error }"
:error="
v$.endPoint.$error ? $t('CAMPAIGN.ADD.FORM.END_POINT.ERROR') : ''
"
:placeholder="$t('CAMPAIGN.ADD.FORM.END_POINT.PLACEHOLDER')"
@blur="v$.endPoint.$touch"
/>
<woot-input
v-if="isOngoingType"
v-model="timeOnPage"
:label="$t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.LABEL')"
type="text"
:class="{ error: v$.timeOnPage.$error }"
:error="
v$.timeOnPage.$error
? $t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.ERROR')
: ''
"
:placeholder="$t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.PLACEHOLDER')"
@blur="v$.timeOnPage.$touch"
/>
<label v-if="isOngoingType">
<input
v-model="enabled"
type="checkbox"
value="enabled"
name="enabled"
/>
{{ $t('CAMPAIGN.ADD.FORM.ENABLED') }}
</label>
<label v-if="isOngoingType">
<input
v-model="triggerOnlyDuringBusinessHours"
type="checkbox"
value="triggerOnlyDuringBusinessHours"
name="triggerOnlyDuringBusinessHours"
/>
{{ $t('CAMPAIGN.ADD.FORM.TRIGGER_ONLY_BUSINESS_HOURS') }}
</label>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button :is-loading="uiFlags.isCreating">
{{ $t('CAMPAIGN.ADD.CREATE_BUTTON_TEXT') }}
</woot-button>
<woot-button variant="clear" @click.prevent="onClose">
{{ $t('CAMPAIGN.ADD.CANCEL_BUTTON_TEXT') }}
</woot-button>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
::v-deep .ProseMirror-woot-style {
height: 5rem;
}
.message-editor {
@apply px-3;
::v-deep {
.ProseMirror-menubar {
@apply rounded-tl-[4px];
}
}
}
</style>

View File

@@ -1,106 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useCampaign } from 'shared/composables/useCampaign';
import CampaignsTable from './CampaignsTable.vue';
import EditCampaign from './EditCampaign.vue';
export default {
components: {
CampaignsTable,
EditCampaign,
},
props: {
type: {
type: String,
default: '',
},
},
setup() {
const { campaignType } = useCampaign();
return { campaignType };
},
data() {
return {
showEditPopup: false,
selectedCampaign: {},
showDeleteConfirmationPopup: false,
};
},
computed: {
...mapGetters({
uiFlags: 'campaigns/getUIFlags',
}),
campaigns() {
return this.$store.getters['campaigns/getCampaigns'](this.campaignType);
},
showEmptyResult() {
const hasEmptyResults =
!this.uiFlags.isFetching && this.campaigns.length === 0;
return hasEmptyResults;
},
},
methods: {
openEditPopup(campaign) {
this.selectedCampaign = campaign;
this.showEditPopup = true;
},
hideEditPopup() {
this.showEditPopup = false;
},
openDeletePopup(campaign) {
this.showDeleteConfirmationPopup = true;
this.selectedCampaign = campaign;
},
closeDeletePopup() {
this.showDeleteConfirmationPopup = false;
},
confirmDeletion() {
this.closeDeletePopup();
const { id } = this.selectedCampaign;
this.deleteCampaign(id);
},
async deleteCampaign(id) {
try {
await this.$store.dispatch('campaigns/delete', id);
useAlert(this.$t('CAMPAIGN.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('CAMPAIGN.DELETE.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<template>
<div class="flex-1 overflow-auto">
<CampaignsTable
:campaigns="campaigns"
:show-empty-result="showEmptyResult"
:is-loading="uiFlags.isFetching"
:campaign-type="type"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
<woot-modal v-model:show="showEditPopup" :on-close="hideEditPopup">
<EditCampaign
:selected-campaign="selectedCampaign"
@on-close="hideEditPopup"
/>
</woot-modal>
<woot-delete-modal
v-model:show="showDeleteConfirmationPopup"
:on-close="closeDeletePopup"
:on-confirm="confirmDeletion"
:title="$t('CAMPAIGN.DELETE.CONFIRM.TITLE')"
:message="$t('CAMPAIGN.DELETE.CONFIRM.MESSAGE')"
:confirm-text="$t('CAMPAIGN.DELETE.CONFIRM.YES')"
:reject-text="$t('CAMPAIGN.DELETE.CONFIRM.NO')"
/>
</div>
</template>
<style scoped lang="scss">
.button-wrapper {
@apply flex justify-end pb-2.5;
}
</style>

View File

@@ -1,115 +0,0 @@
<script setup>
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import InboxName from 'dashboard/components/widgets/InboxName.vue';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { messageStamp } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
import { computed } from 'vue';
const props = defineProps({
campaign: {
type: Object,
required: true,
},
isOngoingType: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const { formatMessage } = useMessageFormatter();
const campaignStatus = computed(() => {
if (props.isOngoingType) {
return props.campaign.enabled
? t('CAMPAIGN.LIST.STATUS.ENABLED')
: t('CAMPAIGN.LIST.STATUS.DISABLED');
}
return props.campaign.campaign_status === 'completed'
? t('CAMPAIGN.LIST.STATUS.COMPLETED')
: t('CAMPAIGN.LIST.STATUS.ACTIVE');
});
const colorScheme = computed(() => {
if (props.isOngoingType) {
return props.campaign.enabled ? 'success' : 'secondary';
}
return props.campaign.campaign_status === 'completed'
? 'secondary'
: 'success';
});
</script>
<template>
<div
class="px-5 py-4 mb-2 bg-white border rounded-md dark:bg-slate-800 border-slate-50 dark:border-slate-900"
>
<div class="flex flex-row items-start justify-between">
<div class="flex flex-col">
<div
class="mb-1 -mt-1 text-base font-medium text-slate-900 dark:text-slate-100"
>
{{ campaign.title }}
</div>
<div
v-dompurify-html="formatMessage(campaign.message)"
class="text-sm line-clamp-1 [&>p]:mb-0"
/>
</div>
<div class="flex flex-row space-x-4">
<woot-button
v-if="isOngoingType"
variant="link"
icon="edit"
color-scheme="secondary"
size="small"
@click="emit('edit', campaign)"
>
{{ $t('CAMPAIGN.LIST.BUTTONS.EDIT') }}
</woot-button>
<woot-button
variant="link"
icon="dismiss-circle"
size="small"
color-scheme="secondary"
@click="emit('delete', campaign)"
>
{{ $t('CAMPAIGN.LIST.BUTTONS.DELETE') }}
</woot-button>
</div>
</div>
<div class="flex flex-row items-center mt-5 space-x-3">
<woot-label
small
:title="campaignStatus"
:color-scheme="colorScheme"
class="mr-3 text-xs"
/>
<InboxName :inbox="campaign.inbox" class="mb-1 ltr:ml-0 rtl:mr-0" />
<UserAvatarWithName
v-if="campaign.sender"
:user="campaign.sender"
class="mb-1"
/>
<div
v-if="campaign.trigger_rules.url"
:title="campaign.trigger_rules.url"
class="w-1/4 mb-1 text-xs text-woot-600 truncate"
>
{{ campaign.trigger_rules.url }}
</div>
<div
v-if="campaign.scheduled_at"
class="w-1/4 mb-1 text-xs text-slate-700 dark:text-slate-500"
>
{{ messageStamp(new Date(campaign.scheduled_at), 'LLL d, h:mm a') }}
</div>
</div>
</div>
</template>

View File

@@ -1,80 +0,0 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import CampaignCard from './CampaignCard.vue';
export default {
components: {
EmptyState,
Spinner,
CampaignCard,
},
props: {
campaigns: {
type: Array,
default: () => [],
},
showEmptyResult: {
type: Boolean,
default: false,
},
isLoading: {
type: Boolean,
default: false,
},
},
emits: ['edit', 'delete'],
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
computed: {
currentInboxId() {
return this.$route.params.inboxId;
},
inbox() {
return this.$store.getters['inboxes/getInbox'](this.currentInboxId);
},
inboxes() {
if (this.isOngoingType) {
return this.$store.getters['inboxes/getWebsiteInboxes'];
}
return this.$store.getters['inboxes/getTwilioInboxes'];
},
emptyMessage() {
if (this.isOngoingType) {
return this.inboxes.length
? this.$t('CAMPAIGN.ONGOING.404')
: this.$t('CAMPAIGN.ONGOING.INBOXES_NOT_FOUND');
}
return this.inboxes.length
? this.$t('CAMPAIGN.ONE_OFF.404')
: this.$t('CAMPAIGN.ONE_OFF.INBOXES_NOT_FOUND');
},
},
};
</script>
<template>
<div class="flex items-center flex-col">
<div v-if="isLoading" class="items-center flex text-base justify-center">
<Spinner color-scheme="primary" />
<span>{{ $t('CAMPAIGN.LIST.LOADING_MESSAGE') }}</span>
</div>
<div v-else class="w-full">
<EmptyState v-if="showEmptyResult" :title="emptyMessage" />
<div v-else class="w-full">
<CampaignCard
v-for="campaign in campaigns"
:key="campaign.id"
:campaign="campaign"
:is-ongoing-type="isOngoingType"
@edit="campaign => $emit('edit', campaign)"
@delete="campaign => $emit('delete', campaign)"
/>
</div>
</div>
</div>
</template>

View File

@@ -1,304 +0,0 @@
<script>
import { mapGetters } from 'vuex';
import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
},
props: {
selectedCampaign: {
type: Object,
default: () => {},
},
},
emits: ['onClose'],
setup() {
const { isOngoingType } = useCampaign();
return { v$: useVuelidate(), isOngoingType };
},
data() {
return {
title: '',
message: '',
selectedSender: '',
selectedInbox: null,
endPoint: '',
timeOnPage: 10,
triggerOnlyDuringBusinessHours: false,
show: true,
enabled: true,
senderList: [],
};
},
validations: {
title: {
required,
},
message: {
required,
},
selectedSender: {
required,
},
endPoint: {
required,
shouldBeAValidURLPattern(value) {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch (error) {
return false;
}
},
shouldStartWithHTTP(value) {
if (value) {
return value.startsWith('https://') || value.startsWith('http://');
}
return false;
},
},
timeOnPage: {
required,
},
selectedInbox: {
required,
},
},
computed: {
...mapGetters({
uiFlags: 'campaigns/getUIFlags',
inboxes: 'inboxes/getTwilioInboxes',
}),
inboxes() {
if (this.isOngoingType) {
return this.$store.getters['inboxes/getWebsiteInboxes'];
}
return this.$store.getters['inboxes/getSMSInboxes'];
},
pageTitle() {
return `${this.$t('CAMPAIGN.EDIT.TITLE')} - ${
this.selectedCampaign.title
}`;
},
sendersAndBotList() {
return [
{
id: 0,
name: 'Bot',
},
...this.senderList,
];
},
},
mounted() {
this.setFormValues();
},
methods: {
onClose() {
this.$emit('onClose');
},
async loadInboxMembers() {
try {
const response = await this.$store.dispatch('inboxMembers/get', {
inboxId: this.selectedInbox,
});
const {
data: { payload: inboxMembers },
} = response;
this.senderList = inboxMembers;
} catch (error) {
const errorMessage =
error?.response?.message || this.$t('CAMPAIGN.ADD.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
},
onChangeInbox() {
this.loadInboxMembers();
},
setFormValues() {
const {
title,
message,
enabled,
trigger_only_during_business_hours: triggerOnlyDuringBusinessHours,
inbox: { id: inboxId },
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
sender,
} = this.selectedCampaign;
this.title = title;
this.message = message;
this.endPoint = endPoint;
this.timeOnPage = timeOnPage;
this.selectedInbox = inboxId;
this.triggerOnlyDuringBusinessHours = triggerOnlyDuringBusinessHours;
this.selectedSender = (sender && sender.id) || 0;
this.enabled = enabled;
this.loadInboxMembers();
},
async editCampaign() {
this.v$.$touch();
if (this.v$.$invalid) {
return;
}
try {
await this.$store.dispatch('campaigns/update', {
id: this.selectedCampaign.id,
title: this.title,
message: this.message,
inbox_id: this.selectedInbox,
trigger_only_during_business_hours:
// eslint-disable-next-line prettier/prettier
this.triggerOnlyDuringBusinessHours,
sender_id: this.selectedSender || null,
enabled: this.enabled,
trigger_rules: {
url: this.endPoint,
time_on_page: this.timeOnPage,
},
});
useAlert(this.$t('CAMPAIGN.EDIT.API.SUCCESS_MESSAGE'));
this.onClose();
} catch (error) {
useAlert(this.$t('CAMPAIGN.EDIT.API.ERROR_MESSAGE'));
}
},
},
};
</script>
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header :header-title="pageTitle" />
<form class="flex flex-col w-full" @submit.prevent="editCampaign">
<div class="w-full">
<woot-input
v-model="title"
:label="$t('CAMPAIGN.ADD.FORM.TITLE.LABEL')"
type="text"
:class="{ error: v$.title.$error }"
:error="v$.title.$error ? $t('CAMPAIGN.ADD.FORM.TITLE.ERROR') : ''"
:placeholder="$t('CAMPAIGN.ADD.FORM.TITLE.PLACEHOLDER')"
@blur="v$.title.$touch"
/>
<div class="editor-wrap">
<label>
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.LABEL') }}
</label>
<WootMessageEditor
v-model="message"
class="message-editor"
is-format-mode
:class="{ editor_warning: v$.message.$error }"
:placeholder="$t('CAMPAIGN.ADD.FORM.MESSAGE.PLACEHOLDER')"
@input="v$.message.$touch"
/>
<span v-if="v$.message.$error" class="editor-warning__message">
{{ $t('CAMPAIGN.ADD.FORM.MESSAGE.ERROR') }}
</span>
</div>
<label :class="{ error: v$.selectedInbox.$error }">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.LABEL') }}
<select v-model="selectedInbox" @change="onChangeInbox($event)">
<option v-for="item in inboxes" :key="item.id" :value="item.id">
{{ item.name }}
</option>
</select>
<span v-if="v$.selectedInbox.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.INBOX.ERROR') }}
</span>
</label>
<label :class="{ error: v$.selectedSender.$error }">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.LABEL') }}
<select v-model="selectedSender">
<option
v-for="sender in sendersAndBotList"
:key="sender.name"
:value="sender.id"
>
{{ sender.name }}
</option>
</select>
<span v-if="v$.selectedSender.$error" class="message">
{{ $t('CAMPAIGN.ADD.FORM.SENT_BY.ERROR') }}
</span>
</label>
<woot-input
v-model="endPoint"
:label="$t('CAMPAIGN.ADD.FORM.END_POINT.LABEL')"
type="text"
:class="{ error: v$.endPoint.$error }"
:error="
v$.endPoint.$error ? $t('CAMPAIGN.ADD.FORM.END_POINT.ERROR') : ''
"
:placeholder="$t('CAMPAIGN.ADD.FORM.END_POINT.PLACEHOLDER')"
@blur="v$.endPoint.$touch"
/>
<woot-input
v-model="timeOnPage"
:label="$t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.LABEL')"
type="text"
:class="{ error: v$.timeOnPage.$error }"
:error="
v$.timeOnPage.$error
? $t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.ERROR')
: ''
"
:placeholder="$t('CAMPAIGN.ADD.FORM.TIME_ON_PAGE.PLACEHOLDER')"
@blur="v$.timeOnPage.$touch"
/>
<label>
<input
v-model="enabled"
type="checkbox"
value="enabled"
name="enabled"
/>
{{ $t('CAMPAIGN.ADD.FORM.ENABLED') }}
</label>
<label v-if="isOngoingType">
<input
v-model="triggerOnlyDuringBusinessHours"
type="checkbox"
value="triggerOnlyDuringBusinessHours"
name="triggerOnlyDuringBusinessHours"
/>
{{ $t('CAMPAIGN.ADD.FORM.TRIGGER_ONLY_BUSINESS_HOURS') }}
</label>
</div>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button :is-loading="uiFlags.isCreating">
{{ $t('CAMPAIGN.EDIT.UPDATE_BUTTON_TEXT') }}
</woot-button>
<woot-button variant="clear" @click.prevent="onClose">
{{ $t('CAMPAIGN.ADD.CANCEL_BUTTON_TEXT') }}
</woot-button>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
::v-deep .ProseMirror-woot-style {
height: 5rem;
}
.message-editor {
@apply px-3;
::v-deep {
.ProseMirror-menubar {
@apply rounded-tl-[4px];
}
}
}
</style>

View File

@@ -1,55 +0,0 @@
<script>
import { useCampaign } from 'shared/composables/useCampaign';
import Campaign from './Campaign.vue';
import AddCampaign from './AddCampaign.vue';
export default {
components: {
Campaign,
AddCampaign,
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
data() {
return { showAddPopup: false };
},
computed: {
buttonText() {
if (this.isOngoingType) {
return this.$t('CAMPAIGN.HEADER_BTN_TXT.ONGOING');
}
return this.$t('CAMPAIGN.HEADER_BTN_TXT.ONE_OFF');
},
},
mounted() {
this.$store.dispatch('campaigns/get');
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
hideAddPopup() {
this.showAddPopup = false;
},
},
};
</script>
<template>
<div class="flex-1 p-4 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup"
>
{{ buttonText }}
</woot-button>
<Campaign />
<woot-modal v-model:show="showAddPopup" :on-close="hideAddPopup">
<AddCampaign @on-close="hideAddPopup" />
</woot-modal>
</div>
</template>

View File

@@ -1,50 +0,0 @@
import { frontendURL } from '../../../../helper/URLHelper';
import SettingsContent from '../Wrapper.vue';
import Index from './Index.vue';
export default {
routes: [
{
path: frontendURL('accounts/:accountId/campaigns'),
component: SettingsContent,
props: {
headerTitle: 'CAMPAIGN.ONGOING.HEADER',
icon: 'arrow-swap',
},
children: [
{
path: '',
redirect: to => {
return { name: 'ongoing_campaigns', params: to.params };
},
},
{
path: 'ongoing',
name: 'ongoing_campaigns',
meta: {
permissions: ['administrator'],
},
component: Index,
},
],
},
{
path: frontendURL('accounts/:accountId/campaigns'),
component: SettingsContent,
props: {
headerTitle: 'CAMPAIGN.ONE_OFF.HEADER',
icon: 'sound-source',
},
children: [
{
path: 'one_off',
name: 'one_off',
meta: {
permissions: ['administrator'],
},
component: Index,
},
],
},
],
};

View File

@@ -11,7 +11,6 @@ import attributes from './attributes/attributes.routes';
import automation from './automation/automation.routes';
import auditlogs from './auditlogs/audit.routes';
import billing from './billing/billing.routes';
import campaigns from './campaigns/campaigns.routes';
import canned from './canned/canned.routes';
import inbox from './inbox/inbox.routes';
import integrations from './integrations/integrations.routes';
@@ -50,7 +49,6 @@ export default {
...automation.routes,
...auditlogs.routes,
...billing.routes,
...campaigns.routes,
...canned.routes,
...inbox.routes,
...integrations.routes,