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,141 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import { getInboxIconByType } from 'dashboard/helper/inbox';
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';
const props = defineProps({
title: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
isLiveChatType: {
type: Boolean,
default: false,
},
isEnabled: {
type: Boolean,
default: false,
},
status: {
type: String,
default: '',
},
sender: {
type: Object,
default: null,
},
inbox: {
type: Object,
default: null,
},
scheduledAt: {
type: Number,
default: 0,
},
});
const emit = defineEmits(['edit', 'delete']);
const { t } = useI18n();
const STATUS_COMPLETED = 'completed';
const { formatMessage } = useMessageFormatter();
const isActive = computed(() =>
props.isLiveChatType ? props.isEnabled : props.status !== STATUS_COMPLETED
);
const statusTextColor = computed(() => ({
'text-n-teal-11': isActive.value,
'text-n-slate-12': !isActive.value,
}));
const campaignStatus = computed(() => {
if (props.isLiveChatType) {
return props.isEnabled
? t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.ENABLED')
: t('CAMPAIGN.LIVE_CHAT.CARD.STATUS.DISABLED');
}
return props.status === STATUS_COMPLETED
? t('CAMPAIGN.SMS.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.SMS.CARD.STATUS.SCHEDULED');
});
const inboxName = computed(() => props.inbox?.name || '');
const inboxIcon = computed(() => {
const { phone_number: phoneNumber, channel_type: type } = props.inbox;
return getInboxIconByType(type, phoneNumber);
});
</script>
<template>
<CardLayout class="flex flex-row justify-between flex-1 gap-8" layout="row">
<template #header>
<div class="flex flex-col items-start gap-2">
<div class="flex justify-between gap-3 w-fit">
<span
class="text-base font-medium capitalize text-n-slate-12 line-clamp-1"
>
{{ title }}
</span>
<span
class="text-xs font-medium inline-flex items-center h-6 px-2 py-0.5 rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
<div
v-dompurify-html="formatMessage(message)"
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
/>
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
<LiveChatCampaignDetails
v-if="isLiveChatType"
:sender="sender"
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
/>
<SMSCampaignDetails
v-else
:inbox-name="inboxName"
:inbox-icon="inboxIcon"
:scheduled-at="scheduledAt"
/>
</div>
</div>
</template>
<template #footer>
<div class="flex items-center justify-end w-20 gap-2">
<Button
v-if="isLiveChatType"
variant="faded"
size="sm"
color="slate"
icon="i-lucide-sliders-vertical"
@click="emit('edit')"
/>
<Button
variant="faded"
color="ruby"
size="sm"
icon="i-lucide-trash"
@click="emit('delete')"
/>
</div>
</template>
</CardLayout>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
import Thumbnail from 'dashboard/components-next/thumbnail/Thumbnail.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
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: '',
},
});
const { t } = useI18n();
const senderName = computed(
() => props.sender?.name || t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.BOT')
);
const senderThumbnailSrc = computed(() => props.sender?.thumbnail);
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.LIVE_CHAT.CARD.CAMPAIGN_DETAILS.SENT_BY') }}
</span>
<div class="flex items-center gap-1.5 flex-shrink-0">
<Thumbnail
:author="sender || { name: senderName }"
:name="senderName"
:src="senderThumbnailSrc"
/>
<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.LIVE_CHAT.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>
</template>

View File

@@ -0,0 +1,41 @@
<script setup>
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { messageStamp } from 'shared/helpers/timeHelper';
import { useI18n } from 'vue-i18n';
defineProps({
inboxName: {
type: String,
default: '',
},
inboxIcon: {
type: String,
default: '',
},
scheduledAt: {
type: Number,
default: 0,
},
});
const { t } = useI18n();
</script>
<template>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.SENT_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>
<span class="flex-shrink-0 text-sm text-n-slate-11 whitespace-nowrap">
{{ t('CAMPAIGN.SMS.CARD.CAMPAIGN_DETAILS.ON') }}
</span>
<span class="flex-1 text-sm font-medium truncate text-n-slate-12">
{{ messageStamp(new Date(scheduledAt), 'LLL d, h:mm a') }}
</span>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
import Button from 'dashboard/components-next/button/Button.vue';
defineProps({
headerTitle: {
type: String,
default: '',
},
buttonLabel: {
type: String,
default: '',
},
});
const emit = defineEmits(['click', 'close']);
const handleButtonClick = () => {
emit('click');
};
</script>
<template>
<section class="flex flex-col w-full h-full overflow-hidden bg-n-background">
<header class="sticky top-0 z-10 px-6 lg:px-0">
<div class="w-full max-w-[900px] mx-auto">
<div class="flex items-center justify-between w-full h-20 gap-2">
<span class="text-xl font-medium text-n-slate-12">
{{ headerTitle }}
</span>
<div
v-on-clickaway="() => emit('close')"
class="relative group/campaign-button"
>
<Button
:label="buttonLabel"
icon="i-lucide-plus"
size="sm"
class="group-hover/campaign-button:brightness-110"
@click="handleButtonClick"
/>
<slot name="action" />
</div>
</div>
</div>
</header>
<main class="flex-1 px-6 overflow-y-auto lg:px-0">
<div class="w-full max-w-[900px] mx-auto py-4">
<slot name="default" />
</div>
</main>
</section>
</template>

View File

@@ -0,0 +1,212 @@
export const ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Chatbot Assistance',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Alexa Rivera',
},
message: 'Hello! 👋 Need help with our chatbot features? Feel free to ask!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://www.chatwoot.com/features/chatbot/',
time_on_page: 10,
},
trigger_only_during_business_hours: true,
created_at: '2024-10-24T13:10:26.636Z',
updated_at: '2024-10-24T13:10:26.636Z',
},
{
id: 2,
title: 'Pricing Information Support',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Jamie Lee',
},
message: 'Hello! 👋 Any questions on pricing? Im here to help!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://www.chatwoot.com/pricings',
time_on_page: 10,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:11:08.763Z',
updated_at: '2024-10-24T13:11:08.763Z',
},
{
id: 3,
title: 'Product Setup Assistance',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Chatwoot',
},
message: 'Hi! Chatwoot here. Need help setting up? Let me know!',
campaign_status: 'active',
enabled: false,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://{*.}?chatwoot.com/apps/account/*/settings/inboxes/new/',
time_on_page: 10,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:11:44.285Z',
updated_at: '2024-10-24T13:11:44.285Z',
},
{
id: 4,
title: 'General Assistance Campaign',
inbox: {
id: 2,
name: 'PaperLayer Website',
channel_type: 'Channel::WebWidget',
phone_number: '',
},
sender: {
id: 1,
name: 'Chris Barlow',
},
message:
'Hi there! 👋 Im here for any questions you may have. Lets chat!',
campaign_status: 'active',
enabled: true,
campaign_type: 'ongoing',
trigger_rules: {
url: 'https://siv.com',
time_on_page: 200,
},
trigger_only_during_business_hours: false,
created_at: '2024-10-29T19:54:33.741Z',
updated_at: '2024-10-29T19:56:26.296Z',
},
];
export const ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT = [
{
id: 1,
title: 'Customer Feedback Request',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message:
'Hello! Enjoying our product? Share your feedback on G2 and earn a $25 Amazon coupon: https://chwt.app/g2-review',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1729775588,
audience: [
{ id: 4, type: 'Label' },
{ id: 5, type: 'Label' },
{ id: 6, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:13:08.496Z',
updated_at: '2024-10-24T13:15:38.698Z',
},
{
id: 2,
title: 'Welcome New Customer',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Welcome aboard! 🎉 Let us know if you have any questions.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1729732500,
audience: [
{ id: 1, type: 'Label' },
{ id: 6, type: 'Label' },
{ id: 5, type: 'Label' },
{ id: 2, type: 'Label' },
{ id: 4, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-24T13:14:00.168Z',
updated_at: '2024-10-24T13:15:38.707Z',
},
{
id: 3,
title: 'New Business Welcome',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Hello! Were excited to have your business with us!',
campaign_status: 'active',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1730368440,
audience: [
{ id: 1, type: 'Label' },
{ id: 3, type: 'Label' },
{ id: 6, type: 'Label' },
{ id: 4, type: 'Label' },
{ id: 2, type: 'Label' },
{ id: 5, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-30T07:54:49.915Z',
updated_at: '2024-10-30T07:54:49.915Z',
},
{
id: 4,
title: 'New Member Onboarding',
inbox: {
id: 6,
name: 'PaperLayer Mobile',
channel_type: 'Channel::Sms',
phone_number: '+29818373149903',
provider: 'default',
},
message: 'Welcome to the team! Reach out if you have questions.',
campaign_status: 'completed',
enabled: true,
campaign_type: 'one_off',
scheduled_at: 1730304840,
audience: [
{ id: 1, type: 'Label' },
{ id: 3, type: 'Label' },
{ id: 6, type: 'Label' },
],
trigger_rules: {},
trigger_only_during_business_hours: false,
created_at: '2024-10-29T16:14:10.374Z',
updated_at: '2024-10-30T16:15:03.157Z',
},
];

View File

@@ -0,0 +1,39 @@
<script setup>
import { ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONGOING_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
is-live-chat-type
/>
</div>
</template>
</EmptyStateLayout>
</template>

View File

@@ -0,0 +1,38 @@
<script setup>
import { ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT } from './CampaignEmptyStateContent';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
title: {
type: String,
default: '',
},
subtitle: {
type: String,
default: '',
},
});
</script>
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="flex flex-col gap-4 p-px">
<CampaignCard
v-for="campaign in ONE_OFF_CAMPAIGN_EMPTY_STATE_CONTENT"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
/>
</div>
</template>
</EmptyStateLayout>
</template>

View File

@@ -0,0 +1,39 @@
<script setup>
import CampaignCard from 'dashboard/components-next/Campaigns/CampaignCard/CampaignCard.vue';
defineProps({
campaigns: {
type: Array,
required: true,
},
isLiveChatType: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['edit', 'delete']);
const handleEdit = campaign => emit('edit', campaign);
const handleDelete = campaign => emit('delete', campaign);
</script>
<template>
<div class="flex flex-col gap-4">
<CampaignCard
v-for="campaign in campaigns"
:key="campaign.id"
:title="campaign.title"
:message="campaign.message"
:is-enabled="campaign.enabled"
:status="campaign.campaign_status"
:trigger-rules="campaign.trigger_rules"
:sender="campaign.sender"
:inbox="campaign.inbox"
:scheduled-at="campaign.scheduled_at"
:is-live-chat-type="isLiveChatType"
@edit="handleEdit(campaign)"
@delete="handleDelete(campaign)"
/>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
import { ref } from 'vue';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
const props = defineProps({
selectedCampaign: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const deleteCampaign = async id => {
if (!id) return;
try {
await store.dispatch('campaigns/delete', id);
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('CAMPAIGN.CONFIRM_DELETE.API.ERROR_MESSAGE'));
}
};
const handleDialogConfirm = async () => {
await deleteCampaign(props.selectedCampaign.id);
dialogRef.value?.close();
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="alert"
:title="t('CAMPAIGN.CONFIRM_DELETE.TITLE')"
:description="t('CAMPAIGN.CONFIRM_DELETE.DESCRIPTION')"
:confirm-button-label="t('CAMPAIGN.CONFIRM_DELETE.CONFIRM')"
@confirm="handleDialogConfirm"
/>
</template>

View File

@@ -0,0 +1,76 @@
<script setup>
import { ref, computed } from 'vue';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
const props = defineProps({
selectedCampaign: {
type: Object,
default: null,
},
});
const { t } = useI18n();
const store = useStore();
const dialogRef = ref(null);
const liveChatCampaignFormRef = ref(null);
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isUpdatingCampaign = computed(() => uiFlags.value.isUpdating);
const isInvalidForm = computed(
() => liveChatCampaignFormRef.value?.isSubmitDisabled
);
const selectedCampaignId = computed(() => props.selectedCampaign.id);
const updateCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/update', {
id: selectedCampaignId.value,
...campaignDetails,
});
useAlert(t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.SUCCESS_MESSAGE'));
dialogRef.value.close();
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.LIVE_CHAT.EDIT.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = () => {
updateCampaign(liveChatCampaignFormRef.value.prepareCampaignDetails());
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
type="edit"
:title="t('CAMPAIGN.LIVE_CHAT.EDIT.TITLE')"
:is-loading="isUpdatingCampaign"
:disable-confirm-button="isUpdatingCampaign || isInvalidForm"
overflow-y-auto
@confirm="handleSubmit"
>
<template #form>
<LiveChatCampaignForm
ref="liveChatCampaignFormRef"
mode="edit"
:selected-campaign="selectedCampaign"
:show-action-buttons="false"
@submit="handleSubmit"
/>
</template>
</Dialog>
</template>

View File

@@ -0,0 +1,54 @@
<script setup>
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 LiveChatCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/LiveChatCampaign/LiveChatCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONGOING,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleClose = () => emit('close');
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
handleClose();
};
</script>
<template>
<div
class="w-[400px] 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.LIVE_CHAT.CREATE.TITLE`) }}
</h3>
<LiveChatCampaignForm
mode="create"
@submit="handleSubmit"
@cancel="handleClose"
/>
</div>
</template>

View File

@@ -0,0 +1,323 @@
<script setup>
import { reactive, computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { URLPattern } from 'urlpattern-polyfill';
import Input from 'dashboard/components-next/input/Input.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
selectedCampaign: {
type: Object,
default: () => ({}),
},
showActionButtons: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const store = useStore();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
inboxes: useMapGetter('inboxes/getWebsiteInboxes'),
};
const senderList = ref([]);
const initialState = {
title: '',
message: '',
inboxId: null,
senderId: 0,
enabled: true,
triggerOnlyDuringBusinessHours: false,
endPoint: '',
timeOnPage: 10,
};
const state = reactive({ ...initialState });
const urlValidators = {
shouldBeAValidURLPattern: value => {
try {
// eslint-disable-next-line
new URLPattern(value);
return true;
} catch {
return false;
}
},
shouldStartWithHTTP: value =>
value ? value.startsWith('https://') || value.startsWith('http://') : false,
};
const validationRules = {
title: { required, minLength: minLength(1) },
message: { required, minLength: minLength(1) },
inboxId: { required },
senderId: { required },
endPoint: { required, ...urlValidators },
timeOnPage: { required },
};
const v$ = useVuelidate(validationRules, state);
const isCreating = computed(() => formState.uiFlags.value.isCreating);
const isSubmitDisabled = computed(() => v$.value.$invalid);
const mapToOptions = (items, valueKey, labelKey) =>
items?.map(item => ({
value: item[valueKey],
label: item[labelKey],
})) ?? [];
const inboxOptions = computed(() =>
mapToOptions(formState.inboxes.value, 'id', 'name')
);
const sendersAndBotList = computed(() => [
{ value: 0, label: 'Bot' },
...mapToOptions(senderList.value, 'id', 'name'),
]);
const getErrorMessage = (field, errorKey) => {
const baseKey = 'CAMPAIGN.LIVE_CHAT.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'),
endPoint: getErrorMessage('endPoint', 'END_POINT'),
timeOnPage: getErrorMessage('timeOnPage', 'TIME_ON_PAGE'),
sender: getErrorMessage('senderId', 'SENT_BY'),
}));
const resetState = () => Object.assign(state, initialState);
const handleCancel = () => emit('cancel');
const handleInboxChange = async inboxId => {
if (!inboxId) {
senderList.value = [];
return;
}
try {
const response = await store.dispatch('inboxMembers/get', { inboxId });
senderList.value = response?.data?.payload ?? [];
} catch (error) {
senderList.value = [];
useAlert(
error?.response?.message ??
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.API.ERROR_MESSAGE')
);
}
};
const prepareCampaignDetails = () => ({
title: state.title,
message: state.message,
inbox_id: state.inboxId,
sender_id: state.senderId || null,
enabled: state.enabled,
trigger_only_during_business_hours: state.triggerOnlyDuringBusinessHours,
trigger_rules: {
url: state.endPoint,
time_on_page: state.timeOnPage,
},
});
const handleSubmit = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
emit('submit', prepareCampaignDetails());
if (props.mode === 'create') {
resetState();
handleCancel();
}
};
const updateStateFromCampaign = campaign => {
if (!campaign) return;
const {
title,
message,
inbox: { id: inboxId },
sender,
enabled,
trigger_only_during_business_hours: triggerOnlyDuringBusinessHours,
trigger_rules: { url: endPoint, time_on_page: timeOnPage },
} = campaign;
Object.assign(state, {
title,
message,
inboxId,
senderId: sender?.id ?? 0,
enabled,
triggerOnlyDuringBusinessHours,
endPoint,
timeOnPage,
});
};
watch(
() => state.inboxId,
newInboxId => {
if (newInboxId) {
handleInboxChange(newInboxId);
}
},
{ immediate: true }
);
watch(
() => props.selectedCampaign,
newCampaign => {
if (props.mode === 'edit' && newCampaign) {
updateStateFromCampaign(newCampaign);
}
},
{ immediate: true }
);
defineExpose({ prepareCampaignDetails, isSubmitDisabled });
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<Input
v-model="state.title"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<Editor
v-model="state.message"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.MESSAGE.PLACEHOLDER')"
: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.LIVE_CHAT.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.LIVE_CHAT.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="sentBy" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.LABEL') }}
</label>
<ComboBox
id="sentBy"
v-model="state.senderId"
:options="sendersAndBotList"
:has-error="!!formErrors.sender"
:disabled="!state.inboxId"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.SENT_BY.PLACEHOLDER')"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
:message="formErrors.sender"
/>
</div>
<Input
v-model="state.endPoint"
type="url"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.LABEL')"
:placeholder="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.END_POINT.PLACEHOLDER')"
:message="formErrors.endPoint"
:message-type="formErrors.endPoint ? 'error' : 'info'"
/>
<Input
v-model="state.timeOnPage"
type="number"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.LABEL')"
:placeholder="
t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.TIME_ON_PAGE.PLACEHOLDER')
"
:message="formErrors.timeOnPage"
:message-type="formErrors.timeOnPage ? 'error' : 'info'"
/>
<fieldset class="flex flex-col gap-2.5">
<legend class="mb-2.5 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TITLE') }}
</legend>
<label class="flex items-center gap-2">
<input v-model="state.enabled" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.ENABLED') }}
</span>
</label>
<label class="flex items-center gap-2">
<input v-model="state.triggerOnlyDuringBusinessHours" type="checkbox" />
<span class="text-sm font-medium text-n-slate-12">
{{
t(
'CAMPAIGN.LIVE_CHAT.CREATE.FORM.OTHER_PREFERENCES.TRIGGER_ONLY_BUSINESS_HOURS'
)
}}
</span>
</label>
</fieldset>
<div
v-if="showActionButtons"
class="flex items-center justify-between w-full gap-3"
>
<Button
type="button"
variant="faded"
color="slate"
:label="t('CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.CANCEL')"
class="w-full bg-n-alpha-2 n-blue-text hover:bg-n-alpha-3"
@click="handleCancel"
/>
<Button
type="submit"
:label="
t(`CAMPAIGN.LIVE_CHAT.CREATE.FORM.BUTTONS.${mode.toUpperCase()}`)
"
class="w-full"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>

View File

@@ -0,0 +1,49 @@
<script setup>
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 SMSCampaignForm from 'dashboard/components-next/Campaigns/Pages/CampaignPage/SMSCampaign/SMSCampaignForm.vue';
const emit = defineEmits(['close']);
const store = useStore();
const { t } = useI18n();
const addCampaign = async campaignDetails => {
try {
await store.dispatch('campaigns/create', campaignDetails);
// tracking this here instead of the store to track the type of campaign
useTrack(CAMPAIGNS_EVENTS.CREATE_CAMPAIGN, {
type: CAMPAIGN_TYPES.ONE_OFF,
});
useAlert(t('CAMPAIGN.SMS.CREATE.FORM.API.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage =
error?.response?.message ||
t('CAMPAIGN.SMS.CREATE.FORM.API.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
const handleSubmit = campaignDetails => {
addCampaign(campaignDetails);
};
const handleClose = () => emit('close');
</script>
<template>
<div
class="w-[400px] 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"
>
<h3 class="text-base font-medium text-slate-900 dark:text-slate-50">
{{ t(`CAMPAIGN.SMS.CREATE.TITLE`) }}
</h3>
<SMSCampaignForm @submit="handleSubmit" @cancel="handleClose" />
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup>
import { reactive, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
const emit = defineEmits(['submit', 'cancel']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
inboxes: useMapGetter('inboxes/getSMSInboxes'),
};
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.SMS.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.SMS.CREATE.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.CREATE.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<TextArea
v-model="state.message"
:label="t('CAMPAIGN.SMS.CREATE.FORM.MESSAGE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.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.SMS.CREATE.FORM.INBOX.LABEL') }}
</label>
<ComboBox
id="inbox"
v-model="state.inboxId"
:options="inboxOptions"
:has-error="!!formErrors.inbox"
:placeholder="t('CAMPAIGN.SMS.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.SMS.CREATE.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.SMS.CREATE.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.SMS.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.SMS.CREATE.FORM.SCHEDULED_AT.LABEL')"
type="datetime-local"
:min="currentDateTime"
:placeholder="t('CAMPAIGN.SMS.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.SMS.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.SMS.CREATE.FORM.BUTTONS.CREATE')"
class="w-full"
type="submit"
:is-loading="isCreating"
:disabled="isCreating || isSubmitDisabled"
/>
</div>
</form>
</template>

View File

@@ -1,4 +1,10 @@
<script setup>
const props = defineProps({
layout: {
type: String,
default: 'col',
},
});
const emit = defineEmits(['click']);
const handleClick = () => {
emit('click');
@@ -7,7 +13,8 @@ const handleClick = () => {
<template>
<div
class="relative flex flex-col w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
class="relative flex w-full gap-3 px-6 py-5 shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2"
:class="props.layout === 'col' ? 'flex-col' : 'flex-row'"
@click="handleClick"
>
<slot name="header" />

View File

@@ -0,0 +1,173 @@
<script setup>
import { computed, ref, watch } from 'vue';
import WootEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
const props = defineProps({
modelValue: {
type: String,
default: '',
},
label: {
type: String,
default: '',
},
placeholder: {
type: String,
default: '',
},
focusOnMount: {
type: Boolean,
default: false,
},
maxLength: {
type: Number,
default: 200,
},
showCharacterCount: {
type: Boolean,
default: true,
},
disabled: {
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
});
const emit = defineEmits(['update:modelValue']);
const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length);
const messageClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'text-n-ruby-9 dark:text-n-ruby-9';
case 'success':
return 'text-green-500 dark:text-green-400';
default:
return 'text-n-slate-11 dark:text-n-slate-11';
}
});
const handleInput = value => {
if (!props.disabled) {
emit('update:modelValue', value);
}
};
const handleFocus = () => {
if (!props.disabled) {
isFocused.value = true;
}
};
const handleBlur = () => {
if (!props.disabled) {
isFocused.value = false;
}
};
watch(
() => props.modelValue,
newValue => {
if (props.maxLength && props.showCharacterCount) {
if (characterCount.value >= props.maxLength) {
emit('update:modelValue', newValue.slice(0, props.maxLength));
}
}
}
);
</script>
<template>
<div class="flex flex-col min-w-0 gap-1">
<label v-if="label" class="mb-0.5 text-sm font-medium text-n-slate-12">
{{ label }}
</label>
<div
class="flex flex-col w-full gap-2 px-3 py-3 transition-all duration-500 ease-in-out border rounded-lg editor-wrapper bg-n-alpha-black2"
:class="[
{
'cursor-not-allowed opacity-50 pointer-events-none !bg-n-alpha-black2 disabled:border-n-weak dark:disabled:border-n-weak':
disabled,
'border-n-brand dark:border-n-brand': isFocused,
'hover:border-n-slate-6 dark:hover:border-n-slate-6 border-n-weak dark:border-n-weak':
!isFocused && messageType !== 'error',
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9':
messageType === 'error' && !isFocused,
},
]"
>
<WootEditor
:model-value="modelValue"
:placeholder="placeholder"
:focus-on-mount="focusOnMount"
:disabled="disabled"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
/>
<div
v-if="showCharacterCount"
class="flex items-center justify-end h-4 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-n-slate-10">
{{ characterCount }} / {{ maxLength }}
</span>
</div>
</div>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>
<style lang="scss" scoped>
.editor-wrapper {
::v-deep {
.ProseMirror-menubar-wrapper {
@apply gap-2 !important;
.ProseMirror-menubar {
@apply bg-transparent dark:bg-transparent w-fit left-1 pt-0 h-5 !important;
.ProseMirror-menuitem {
@apply h-5 !important;
}
.ProseMirror-icon {
@apply p-1 w-3 h-3 text-n-slate-12 dark:text-n-slate-12 !important;
}
}
.ProseMirror.ProseMirror-woot-style {
p {
@apply first:mt-0 !important;
}
.empty-node {
@apply m-0 !important;
&::before {
@apply text-n-slate-11 dark:text-n-slate-11 !important;
}
}
}
}
}
}
</style>

View File

@@ -34,7 +34,7 @@ defineProps({
{{ title }}
</h2>
<p
class="max-w-lg text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
class="max-w-xl text-base text-center text-slate-600 dark:text-slate-300 font-interDisplay tracking-[0.3px]"
>
{{ subtitle }}
</p>

View File

@@ -33,7 +33,7 @@ const onClick = () => {
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-1 gap-4 overflow-hidden">
<div class="grid grid-cols-1 gap-4 p-px overflow-hidden">
<ArticleCard
v-for="(article, index) in articleContent.slice(0, 5)"
:id="article.id"

View File

@@ -18,7 +18,7 @@ defineProps({
<template>
<EmptyStateLayout :title="title" :subtitle="subtitle">
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 p-px">
<div class="space-y-4">
<CategoryCard
v-for="category in categoryContent"

View File

@@ -28,7 +28,7 @@ const onPortalCreate = ({ slug: portalSlug, locale }) => {
:subtitle="$t('HELP_CENTER.NEW_PAGE.DESCRIPTION')"
>
<template #empty-state-item>
<div class="grid grid-cols-2 gap-4">
<div class="grid grid-cols-2 gap-4 p-px">
<div class="space-y-4">
<ArticleCard
v-for="(article, index) in articleContent"

View File

@@ -67,7 +67,7 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="text-xl font-medium text-slate-900 dark:text-white"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>

View File

@@ -157,24 +157,24 @@ defineExpose({ state, isSubmitDisabled });
<template>
<div class="flex flex-col gap-4">
<div
class="flex items-center justify-start gap-8 px-4 py-2 border rounded-lg border-slate-50 dark:border-slate-700/50"
class="flex items-center justify-start gap-8 px-4 py-2 border rounded-lg border-n-strong"
>
<div class="flex flex-col items-start w-full gap-2 py-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
<span class="text-sm font-medium text-n-slate-11">
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.PORTAL') }}
</span>
<span class="text-sm text-slate-800 dark:text-slate-100">
<span class="text-sm text-n-slate-12">
{{ portalName }}
</span>
</div>
<div class="justify-start w-px h-10 bg-slate-50 dark:bg-slate-700/50" />
<div class="justify-start w-px h-10 bg-n-strong" />
<div class="flex flex-col w-full gap-2 py-2">
<span class="text-sm font-medium text-slate-700 dark:text-slate-300">
<span class="text-sm font-medium text-n-slate-11">
{{ t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_DIALOG.HEADER.LOCALE') }}
</span>
<span
:title="`${activeLocaleName} (${activeLocaleCode})`"
class="text-sm line-clamp-1 text-slate-800 dark:text-slate-100"
class="text-sm line-clamp-1 text-n-slate-12"
>
{{ `${activeLocaleName} (${activeLocaleCode})` }}
</span>
@@ -192,7 +192,7 @@ defineExpose({ state, isSubmitDisabled });
"
:message="nameError"
:message-type="nameError ? 'error' : 'info'"
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12 !bg-slate-25 dark:!bg-slate-900"
custom-input-class="!h-10 ltr:!pl-12 rtl:!pr-12"
>
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
@@ -223,7 +223,7 @@ defineExpose({ state, isSubmitDisabled });
:disabled="isEditMode"
:message="slugError ? slugError : slugHelpText"
:message-type="slugError ? 'error' : 'info'"
custom-input-class="!h-10 !bg-slate-25 dark:!bg-slate-900 "
custom-input-class="!h-10"
/>
<TextArea
v-model="state.description"
@@ -236,7 +236,6 @@ defineExpose({ state, isSubmitDisabled });
)
"
show-character-count
custom-text-area-wrapper-class="!bg-slate-25 dark:!bg-slate-900"
/>
<div
v-if="showActionButtons"

View File

@@ -48,14 +48,14 @@ const STYLE_CONFIG = {
solid: 'bg-n-brand text-white hover:brightness-110 outline-transparent',
faded:
'bg-n-brand/10 text-n-slate-12 hover:bg-n-brand/20 outline-transparent',
outline: 'text-n-blue-text hover:bg-n-brand/10 outline-n-blue-border',
outline: 'text-n-blue-text outline-n-blue-border',
link: 'text-n-brand hover:underline outline-transparent',
},
ruby: {
solid: 'bg-n-ruby-9 text-white hover:bg-n-ruby-10 outline-transparent',
faded:
'bg-n-ruby-9/10 text-n-slate-12 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-9',
'bg-n-ruby-9/10 text-n-ruby-11 hover:bg-n-ruby-9/20 outline-transparent',
outline: 'text-n-ruby-11 hover:bg-n-ruby-9/10 outline-n-ruby-8',
link: 'text-n-ruby-9 hover:underline outline-transparent',
},
amber: {

View File

@@ -4,6 +4,7 @@ import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import Button from 'dashboard/components-next/button/Button.vue';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
const props = defineProps({
options: {
@@ -36,6 +37,10 @@ const props = defineProps({
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
@@ -45,7 +50,7 @@ const { t } = useI18n();
const selectedValue = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const searchInput = ref(null);
const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
@@ -70,11 +75,13 @@ const selectOption = option => {
open.value = false;
search.value = '';
};
const toggleDropdown = () => {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => searchInput.value.focus());
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -94,67 +101,40 @@ watch(
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
@click.prevent
>
<OnClickOutside @trigger="open = false">
<Button
variant="outline"
color="slate"
:color="hasError && !open ? 'ruby' : open ? 'blue' : 'slate'"
:label="selectedLabel"
trailing-icon
:disabled="disabled"
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6"
:class="{ focused: open }"
:icon="open ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
@click="toggleDropdown"
/>
<div
v-show="open"
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<input
ref="searchInput"
v-model="search"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
/>
</div>
<ul
class="py-1 mb-0 overflow-auto max-h-60"
role="listbox"
:aria-activedescendant="selectedValue"
>
<li
v-for="option in filteredOptions"
:key="option.value"
class="flex items-center justify-between !text-n-slate-12 w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': option.value === selectedValue,
}"
role="option"
:aria-selected="option.value === selectedValue"
@click="selectOption(option)"
>
<span :class="{ 'font-medium': option.value === selectedValue }">
{{ option.label }}
</span>
<span
v-if="option.value === selectedValue"
class="flex-shrink-0 i-lucide-check size-4 text-n-slate-11"
/>
</li>
<li
v-if="filteredOptions.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
<ComboBoxDropdown
ref="dropdownRef"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
:selected-values="selectedValue"
@update:search-value="search = $event"
@select="selectOption"
/>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out text-n-slate-11 dark:text-n-slate-11"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="{
'text-n-ruby-9': hasError,
'text-n-slate-11': !hasError,
}"
>
{{ message }}
</p>

View File

@@ -0,0 +1,107 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
open: {
type: Boolean,
required: true,
},
options: {
type: Array,
required: true,
},
searchValue: {
type: String,
required: true,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
multiple: {
type: Boolean,
default: false,
},
selectedValues: {
type: [String, Number, Array],
default: () => [],
},
});
const emit = defineEmits(['update:searchValue', 'select']);
const { t } = useI18n();
const searchInput = ref(null);
const isSelected = option => {
if (Array.isArray(props.selectedValues)) {
return props.selectedValues.includes(option.value);
}
return option.value === props.selectedValues;
};
defineExpose({
focus: () => searchInput.value?.focus(),
});
</script>
<template>
<div
v-show="open"
class="absolute z-50 w-full mt-1 transition-opacity duration-200 border rounded-md shadow-lg bg-n-solid-1 border-n-strong"
>
<div class="relative border-b border-n-strong">
<span class="absolute i-lucide-search top-2.5 size-4 left-3" />
<input
ref="searchInput"
:value="searchValue"
type="search"
:placeholder="searchPlaceholder || t('COMBOBOX.SEARCH_PLACEHOLDER')"
class="w-full py-2 pl-10 pr-2 text-sm border-none rounded-t-md bg-n-solid-1 text-slate-900 dark:text-slate-50"
@input="emit('update:searchValue', $event.target.value)"
/>
</div>
<ul
class="py-1 mb-0 overflow-auto max-h-60"
role="listbox"
:aria-multiselectable="multiple"
>
<li
v-for="option in options"
:key="option.value"
class="flex items-center justify-between w-full gap-2 px-3 py-2 text-sm transition-colors duration-150 cursor-pointer hover:bg-n-alpha-2"
:class="{
'bg-n-alpha-2': isSelected(option),
}"
role="option"
:aria-selected="isSelected(option)"
@click="emit('select', option)"
>
<span
:class="{
'font-medium': isSelected(option),
}"
class="text-n-slate-12"
>
{{ option.label }}
</span>
<span
v-if="isSelected(option)"
class="flex-shrink-0 i-lucide-check size-4 text-n-slate-11"
/>
</li>
<li
v-if="options.length === 0"
class="px-3 py-2 text-sm text-slate-600 dark:text-slate-300"
>
{{ emptyState || t('COMBOBOX.EMPTY_STATE') }}
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,183 @@
<script setup>
import { ref, computed, watch, nextTick } from 'vue';
import { OnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import ComboBoxDropdown from 'dashboard/components-next/combobox/ComboBoxDropdown.vue';
const props = defineProps({
options: {
type: Array,
required: true,
validator: value =>
value.every(option => 'value' in option && 'label' in option),
},
placeholder: {
type: String,
default: '',
},
modelValue: {
type: Array,
default: () => [],
},
disabled: {
type: Boolean,
default: false,
},
searchPlaceholder: {
type: String,
default: '',
},
emptyState: {
type: String,
default: '',
},
message: {
type: String,
default: '',
},
hasError: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['update:modelValue']);
const { t } = useI18n();
const selectedValues = ref(props.modelValue);
const open = ref(false);
const search = ref('');
const dropdownRef = ref(null);
const comboboxRef = ref(null);
const filteredOptions = computed(() => {
const searchTerm = search.value.toLowerCase();
return props.options.filter(option =>
option.label?.toLowerCase().includes(searchTerm)
);
});
const selectPlaceholder = computed(() => {
return props.placeholder || t('COMBOBOX.PLACEHOLDER');
});
const selectedTags = computed(() => {
return selectedValues.value.map(value => {
const option = props.options.find(opt => opt.value === value);
return option || { value, label: value };
});
});
const toggleOption = option => {
const index = selectedValues.value.indexOf(option.value);
if (index === -1) {
selectedValues.value.push(option.value);
} else {
selectedValues.value.splice(index, 1);
}
emit('update:modelValue', selectedValues.value);
};
const removeTag = value => {
const index = selectedValues.value.indexOf(value);
if (index !== -1) {
selectedValues.value.splice(index, 1);
emit('update:modelValue', selectedValues.value);
}
};
const toggleDropdown = () => {
if (props.disabled) return;
open.value = !open.value;
if (open.value) {
search.value = '';
nextTick(() => dropdownRef.value?.focus());
}
};
watch(
() => props.modelValue,
newValue => {
selectedValues.value = newValue;
}
);
defineExpose({
toggleDropdown,
open,
disabled: props.disabled,
});
</script>
<template>
<div
ref="comboboxRef"
class="relative w-full min-w-0"
:class="{
'cursor-not-allowed': disabled,
'group/combobox': !disabled,
}"
@click.prevent
>
<OnClickOutside @trigger="open = false">
<div
class="flex flex-wrap w-full gap-2 px-3 py-2.5 border rounded-lg cursor-pointer bg-n-alpha-black2 min-h-[42px] transition-all duration-500 ease-in-out"
:class="{
'border-n-ruby-8': hasError,
'border-n-weak dark:border-n-weak hover:border-n-slate-6 dark:hover:border-n-slate-6':
!hasError && !open,
'border-n-brand': open,
'cursor-not-allowed pointer-events-none opacity-50': disabled,
}"
@click="toggleDropdown"
>
<div
v-for="tag in selectedTags"
:key="tag.value"
class="flex items-center justify-center max-w-full gap-1 px-2 py-0.5 rounded-lg bg-n-alpha-black1"
@click.stop
>
<span class="flex-grow min-w-0 text-sm truncate text-n-slate-12">
{{ tag.label }}
</span>
<span
class="flex-shrink-0 cursor-pointer i-lucide-x size-3 text-n-slate-11"
@click="removeTag(tag.value)"
/>
</div>
<span
v-if="selectedTags.length === 0"
class="flex items-center text-sm text-n-slate-11"
>
{{ selectPlaceholder }}
</span>
</div>
<ComboBoxDropdown
ref="dropdownRef"
:open="open"
:options="filteredOptions"
:search-value="search"
:search-placeholder="searchPlaceholder"
:empty-state="emptyState"
multiple
:selected-values="selectedValues"
@update:search-value="search = $event"
@select="toggleOption"
/>
<p
v-if="message"
class="mt-2 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="{
'text-n-ruby-9': hasError,
'text-n-slate-11': !hasError,
}"
>
{{ message }}
</p>
</OnClickOutside>
</div>
</template>

View File

@@ -0,0 +1,55 @@
<script setup>
import { ref } from 'vue';
import TagMultiSelectComboBox from './TagMultiSelectComboBox.vue';
const options = [
{ value: 1, label: 'Option 1' },
{ value: 2, label: 'Option 2' },
{ value: 3, label: 'Option 3' },
{ value: 4, label: 'Option 4' },
{ value: 5, label: 'Option 5' },
];
const selectedValues = ref([]);
const preselectedValues = ref([1, 2]);
</script>
<template>
<Story
title="Components/TagMultiSelectComboBox"
:layout="{ type: 'grid', width: '300px' }"
>
<Variant title="Default">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox v-model="selectedValues" :options="options" />
<p class="mt-2">Selected values: {{ selectedValues }}</p>
</div>
</Variant>
<Variant title="With Preselected Values">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox
v-model="preselectedValues"
:options="options"
placeholder="Select multiple options"
/>
</div>
</Variant>
<Variant title="Disabled">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox :options="options" disabled />
</div>
</Variant>
<Variant title="With Error">
<div class="w-full p-4 bg-white h-80 dark:bg-slate-900">
<TagMultiSelectComboBox
:options="options"
has-error
message="This field is required"
/>
</div>
</Variant>
</Story>
</template>

View File

@@ -44,6 +44,10 @@ defineProps({
type: Boolean,
default: true,
},
overflowYAuto: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['confirm', 'close']);
@@ -73,7 +77,8 @@ defineExpose({ open, close });
<Teleport to="body">
<dialog
ref="dialogRef"
class="w-full max-w-lg overflow-visible transition-all duration-300 ease-in-out shadow-xl rounded-xl"
class="w-full max-w-lg transition-all duration-300 ease-in-out shadow-xl rounded-xl"
:class="overflowYAuto ? 'overflow-y-auto' : 'overflow-visible'"
:dir="isRTL ? 'rtl' : 'ltr'"
@close="close"
>

View File

@@ -38,6 +38,10 @@ const props = defineProps({
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
min: {
type: String,
default: '',
},
});
const emit = defineEmits(['update:modelValue', 'blur', 'input']);
@@ -73,7 +77,7 @@ const handleInput = event => {
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
@@ -86,7 +90,8 @@ const handleInput = event => {
:type="type"
:placeholder="placeholder"
:disabled="disabled"
class="flex w-full reset-base text-sm h-10 !px-2 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-white dark:bg-slate-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-200 dark:placeholder:text-slate-500 disabled:cursor-not-allowed disabled:opacity-50 text-slate-900 dark:text-white transition-all duration-500 ease-in-out"
:min="['date', 'datetime-local', 'time'].includes(type) ? min : undefined"
class="block w-full reset-base text-sm h-10 !px-3 !py-2.5 !mb-0 border rounded-lg focus:border-n-brand dark:focus:border-n-brand bg-n-alpha-black2 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 disabled:cursor-not-allowed disabled:opacity-50 text-n-slate-12 transition-all duration-500 ease-in-out"
@input="handleInput"
@blur="emit('blur')"
/>

View File

@@ -266,14 +266,14 @@ const menuItems = computed(() => {
icon: 'i-lucide-megaphone',
children: [
{
name: 'Ongoing',
label: t('SIDEBAR.ONGOING'),
to: accountScopedRoute('ongoing_campaigns'),
name: 'Live chat',
label: t('SIDEBAR.LIVE_CHAT'),
to: accountScopedRoute('campaigns_livechat_index'),
},
{
name: 'One-off',
label: t('SIDEBAR.ONE_OFF'),
to: accountScopedRoute('one_off'),
name: 'SMS',
label: t('SIDEBAR.SMS'),
to: accountScopedRoute('campaigns_sms_index'),
},
],
},
@@ -281,9 +281,6 @@ const menuItems = computed(() => {
name: 'Portals',
label: t('SIDEBAR.HELP_CENTER.TITLE'),
icon: 'i-lucide-library-big',
to: accountScopedRoute('portals_index', {
navigationPath: 'portals_articles_index',
}),
children: [
{
name: 'Articles',
@@ -442,7 +439,7 @@ const menuItems = computed(() => {
<template>
<aside
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pt-2 pb-1"
class="w-[200px] bg-n-solid-2 rtl:border-l ltr:border-r border-n-weak h-screen flex flex-col text-sm pb-1"
>
<section class="grid gap-2 mt-2 mb-4">
<div class="flex items-center min-w-0 gap-2 px-2">
@@ -490,7 +487,7 @@ const menuItems = computed(() => {
</ul>
</nav>
<section
class="p-1 border-t border-n-strong shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
class="p-1 border-t border-n-weak shadow-[0px_-2px_4px_0px_rgba(27,28,29,0.02)] flex-shrink-0 flex justify-between gap-2 items-center"
>
<SidebarProfileMenu
@open-key-shortcut-modal="emit('openKeyShortcutModal')"

View File

@@ -58,6 +58,15 @@ const props = defineProps({
type: Boolean,
default: false,
},
message: {
type: String,
default: '',
},
messageType: {
type: String,
default: 'info',
validator: value => ['info', 'error', 'success'].includes(value),
},
});
const emit = defineEmits(['update:modelValue']);
@@ -67,6 +76,17 @@ const isFocused = ref(false);
const characterCount = computed(() => props.modelValue.length);
const messageClass = computed(() => {
switch (props.messageType) {
case 'error':
return 'text-n-ruby-9 dark:text-n-ruby-9';
case 'success':
return 'text-green-500 dark:text-green-400';
default:
return 'text-n-slate-11 dark:text-n-slate-11';
}
});
// TODO - use "field-sizing: content" and "height: auto" in future for auto height, when available.
const adjustHeight = () => {
if (!props.autoHeight || !textareaRef.value) return;
@@ -85,11 +105,15 @@ const handleInput = event => {
};
const handleFocus = () => {
isFocused.value = true;
if (!props.disabled) {
isFocused.value = true;
}
};
const handleBlur = () => {
isFocused.value = false;
if (!props.disabled) {
isFocused.value = false;
}
};
// Watch for changes in modelValue to adjust height
@@ -118,19 +142,22 @@ onMounted(() => {
<label
v-if="label"
:for="id"
class="mb-0.5 text-sm font-medium text-gray-900 dark:text-gray-50"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ label }}
</label>
<div
class="flex flex-col gap-2 px-3 pt-3 pb-3 transition-all duration-500 ease-in-out bg-white border rounded-lg border-n-weak dark:border-n-weak dark:bg-slate-900"
class="flex flex-col gap-2 px-3 pt-3 pb-3 transition-all duration-500 ease-in-out border rounded-lg bg-n-alpha-black2"
:class="[
customTextAreaWrapperClass,
{
'cursor-not-allowed opacity-50 !bg-slate-25 dark:!bg-slate-800 disabled:border-n-weak dark:disabled:border-n-weak':
'cursor-not-allowed opacity-50 !bg-n-alpha-black2 disabled:border-n-weak dark:disabled:border-n-weak':
disabled,
'border-n-brand dark:border-n-brand': isFocused,
'hover:border-n-slate-6 dark:hover:border-n-slate-6': !isFocused,
'hover:border-n-slate-6 dark:hover:border-n-slate-6 border-n-weak dark:border-n-weak':
!isFocused && messageType !== 'error',
'border-n-ruby-8 dark:border-n-ruby-8 hover:border-n-ruby-9 dark:hover:border-n-ruby-9':
messageType === 'error' && !isFocused,
},
]"
>
@@ -152,7 +179,7 @@ onMounted(() => {
}"
:disabled="disabled"
rows="1"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-slate-200 dark:placeholder:text-slate-500 text-slate-900 dark:text-white disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
class="flex w-full reset-base text-sm p-0 !rounded-none !bg-transparent dark:!bg-transparent !border-0 !mb-0 placeholder:text-n-slate-11 dark:placeholder:text-n-slate-11 text-n-slate-12 dark:text-n-slate-12 disabled:cursor-not-allowed disabled:opacity-50 disabled:bg-slate-25 dark:disabled:bg-slate-900"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -161,10 +188,17 @@ onMounted(() => {
v-if="showCharacterCount"
class="flex items-center justify-end h-4 mt-1 bottom-3 ltr:right-3 rtl:left-3"
>
<span class="text-xs tabular-nums text-slate-300 dark:text-slate-600">
<span class="text-xs tabular-nums text-n-slate-10">
{{ characterCount }} / {{ maxLength }}
</span>
</div>
</div>
<p
v-if="message"
class="min-w-0 mt-1 mb-0 text-xs truncate transition-all duration-500 ease-in-out"
:class="messageClass"
>
{{ message }}
</p>
</div>
</template>

View File

@@ -77,7 +77,7 @@ const onImgLoad = () => {
class="flex items-center justify-center rounded-full bg-n-slate-3 dark:bg-n-slate-4"
:style="{ width: `${size}px`, height: `${size}px` }"
>
<div v-if="author">
<div v-if="author" class="flex items-center justify-center">
<img
v-if="shouldShowImage"
:src="src"

View File

@@ -2,23 +2,23 @@ import { frontendURL } from '../../../../helper/URLHelper';
const campaigns = accountId => ({
parentNav: 'campaigns',
routes: ['ongoing_campaigns', 'one_off'],
routes: ['campaigns_sms_index', 'campaigns_livechat_index'],
menuItems: [
{
icon: 'arrow-swap',
label: 'ONGOING',
label: 'LIVE_CHAT',
key: 'ongoingCampaigns',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/ongoing`),
toStateName: 'ongoing_campaigns',
toState: frontendURL(`accounts/${accountId}/campaigns/live_chat`),
toStateName: 'campaigns_livechat_index',
},
{
key: 'oneOffCampaigns',
icon: 'sound-source',
label: 'ONE_OFF',
label: 'SMS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/campaigns/one_off`),
toStateName: 'one_off',
toState: frontendURL(`accounts/${accountId}/campaigns/sms`),
toStateName: 'campaigns_sms_index',
},
],
});

View File

@@ -47,7 +47,7 @@ const primaryMenuItems = accountId => [
label: 'CAMPAIGNS',
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'ongoing_campaigns',
toStateName: 'campaigns_ongoing_index',
},
{
icon: 'library',

View File

@@ -65,6 +65,7 @@ const props = defineProps({
modelValue: { type: String, default: '' },
editorId: { type: String, default: '' },
placeholder: { type: String, default: '' },
disabled: { type: Boolean, default: false },
isPrivate: { type: Boolean, default: false },
enableSuggestions: { type: Boolean, default: true },
overrideLineBreaks: { type: Boolean, default: false },
@@ -299,6 +300,8 @@ function handleEmptyBodyWithSignature() {
}
function focusEditor(content) {
if (props.disabled) return;
const unrefContent = unref(content);
if (isBodyEmpty(unrefContent) && sendWithSignature.value) {
// reload state can be called when switching between conversations, or when drafts is loaded
@@ -561,6 +564,7 @@ function onKeydown(event) {
function createEditorView() {
editorView = new EditorView(editor.value, {
state: state,
editable: () => !props.disabled,
dispatchTransaction: tx => {
state = state.apply(tx);
editorView.updateState(state);
@@ -570,17 +574,21 @@ function createEditorView() {
},
handleDOMEvents: {
keyup: () => {
typingIndicator.start();
updateImgToolbarOnDelete();
if (!props.disabled) {
typingIndicator.start();
updateImgToolbarOnDelete();
}
},
keydown: (view, event) => onKeydown(event),
focus: () => emit('focus'),
click: isEditorMouseFocusedOnAnImage,
keydown: (view, event) => !props.disabled && onKeydown(event),
focus: () => !props.disabled && emit('focus'),
click: () => !props.disabled && isEditorMouseFocusedOnAnImage(),
blur: () => {
if (props.disabled) return;
typingIndicator.stop();
emit('blur');
},
paste: (_view, event) => {
if (props.disabled) return;
const data = event.clipboardData.files;
if (data.length > 0) {
event.preventDefault();

View File

@@ -1,4 +1,28 @@
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
export const INBOX_TYPES = {
WEB: 'Channel::WebWidget',
FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
WHATSAPP: 'Channel::Whatsapp',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
};
const INBOX_ICON_MAP = {
[INBOX_TYPES.WEB]: 'i-ri-global-fill',
[INBOX_TYPES.FB]: 'i-ri-messenger-fill',
[INBOX_TYPES.TWITTER]: 'i-ri-twitter-x-fill',
[INBOX_TYPES.WHATSAPP]: 'i-ri-whatsapp-fill',
[INBOX_TYPES.API]: 'i-ri-cloudy-fill',
[INBOX_TYPES.EMAIL]: 'i-ri-mail-fill',
[INBOX_TYPES.TELEGRAM]: 'i-ri-telegram-fill',
[INBOX_TYPES.LINE]: 'i-ri-line-fill',
};
const DEFAULT_ICON = 'i-ri-chat-1-fill';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
@@ -86,6 +110,17 @@ export const getInboxClassByType = (type, phoneNumber) => {
}
};
export const getInboxIconByType = (type, phoneNumber) => {
// Special case for Twilio (whatsapp and sms)
if (type === INBOX_TYPES.TWILIO) {
return phoneNumber?.startsWith('whatsapp')
? 'i-ri-whatsapp-fill'
: 'i-ri-chat-1-fill';
}
return INBOX_ICON_MAP[type] ?? DEFAULT_ICON;
};
export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
const allowedInboxTypes = [INBOX_TYPES.FB, INBOX_TYPES.EMAIL];
if (allowedInboxTypes.includes(type) && reauthorizationRequired) {

View File

@@ -1,4 +1,9 @@
import { getInboxClassByType, getInboxWarningIconClass } from '../inbox';
import {
INBOX_TYPES,
getInboxClassByType,
getInboxIconByType,
getInboxWarningIconClass,
} from '../inbox';
describe('#Inbox Helpers', () => {
describe('getInboxClassByType', () => {
@@ -35,6 +40,74 @@ describe('#Inbox Helpers', () => {
});
});
describe('getInboxIconByType', () => {
it('returns correct icon for web widget', () => {
expect(getInboxIconByType(INBOX_TYPES.WEB)).toBe('i-ri-global-fill');
});
it('returns correct icon for Facebook', () => {
expect(getInboxIconByType(INBOX_TYPES.FB)).toBe('i-ri-messenger-fill');
});
it('returns correct icon for Twitter', () => {
expect(getInboxIconByType(INBOX_TYPES.TWITTER)).toBe(
'i-ri-twitter-x-fill'
);
});
describe('Twilio cases', () => {
it('returns WhatsApp icon for Twilio WhatsApp number', () => {
expect(
getInboxIconByType(INBOX_TYPES.TWILIO, 'whatsapp:+1234567890')
).toBe('i-ri-whatsapp-fill');
});
it('returns SMS icon for regular Twilio number', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, '+1234567890')).toBe(
'i-ri-chat-1-fill'
);
});
it('returns SMS icon when phone number is undefined', () => {
expect(getInboxIconByType(INBOX_TYPES.TWILIO, undefined)).toBe(
'i-ri-chat-1-fill'
);
});
});
it('returns correct icon for WhatsApp', () => {
expect(getInboxIconByType(INBOX_TYPES.WHATSAPP)).toBe(
'i-ri-whatsapp-fill'
);
});
it('returns correct icon for API', () => {
expect(getInboxIconByType(INBOX_TYPES.API)).toBe('i-ri-cloudy-fill');
});
it('returns correct icon for Email', () => {
expect(getInboxIconByType(INBOX_TYPES.EMAIL)).toBe('i-ri-mail-fill');
});
it('returns correct icon for Telegram', () => {
expect(getInboxIconByType(INBOX_TYPES.TELEGRAM)).toBe(
'i-ri-telegram-fill'
);
});
it('returns correct icon for Line', () => {
expect(getInboxIconByType(INBOX_TYPES.LINE)).toBe('i-ri-line-fill');
});
it('returns default icon for unknown type', () => {
expect(getInboxIconByType('UNKNOWN_TYPE')).toBe('i-ri-chat-1-fill');
});
it('returns default icon for undefined type', () => {
expect(getInboxIconByType(undefined)).toBe('i-ri-chat-1-fill');
});
});
describe('getInboxWarningIconClass', () => {
it('should return correct class for warning', () => {
expect(getInboxWarningIconClass('Channel::FacebookPage', true)).toEqual(

View File

@@ -1,126 +1,150 @@
{
"CAMPAIGN": {
"HEADER": "Campaigns",
"SIDEBAR_TXT": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations. Click on <b>Add Campaign</b> to create a new campaign. You can also edit or delete an existing campaign by clicking on the Edit or Delete button.",
"HEADER_BTN_TXT": {
"ONE_OFF": "Create a one off campaign",
"ONGOING": "Create a ongoing campaign"
},
"ADD": {
"TITLE": "Create a campaign",
"DESC": "Proactive messages allow the customer to send outbound messages to their contacts which would trigger more conversations.",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
"LIVE_CHAT": {
"HEADER_TITLE": "Live chat campaigns",
"NEW_CAMPAIGN": "Create campaign",
"CARD": {
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"CONFIRM": "Confirm",
"ERROR": "Scheduled time is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
"ERROR": "Message is required"
},
"SENT_BY": {
"LABEL": "Sent by",
"PLACEHOLDER": "Please select the the content of campaign",
"ERROR": "Sender is required"
},
"END_POINT": {
"LABEL": "URL",
"PLACEHOLDER": "Please enter the URL",
"ERROR": "Please enter a valid URL"
},
"TIME_ON_PAGE": {
"LABEL": "Time on page(Seconds)",
"PLACEHOLDER": "Please enter the time",
"ERROR": "Time on page is required"
},
"ENABLED": "Enable campaign",
"TRIGGER_ONLY_BUSINESS_HOURS": "Trigger only during business hours",
"SUBMIT": "Add Campaign"
"CAMPAIGN_DETAILS": {
"SENT_BY": "Sent by",
"BOT": "Bot",
"FROM": "from",
"URL": "URL:"
}
},
"API": {
"SUCCESS_MESSAGE": "Campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
"EMPTY_STATE": {
"TITLE": "No live chat campaigns are available",
"SUBTITLE": "Connect with your customers using proactive messages. Click 'Create campaign' to get started."
},
"CREATE": {
"TITLE": "Create a live chat campaign",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
"ERROR": "Message is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"SENT_BY": {
"LABEL": "Sent by",
"PLACEHOLDER": "Please select sender",
"ERROR": "Sender is required"
},
"END_POINT": {
"LABEL": "URL",
"PLACEHOLDER": "Please enter the URL",
"ERROR": "Please enter a valid URL"
},
"TIME_ON_PAGE": {
"LABEL": "Time on page(Seconds)",
"PLACEHOLDER": "Please enter the time",
"ERROR": "Time on page is required"
},
"OTHER_PREFERENCES": {
"TITLE": "Other preferences",
"ENABLED": "Enable campaign",
"TRIGGER_ONLY_BUSINESS_HOURS": "Trigger only during business hours"
},
"BUTTONS": {
"CREATE": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "Live chat campaign created successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
},
"EDIT": {
"TITLE": "Edit live chat campaign",
"FORM": {
"API": {
"SUCCESS_MESSAGE": "Live chat campaign updated successfully",
"ERROR_MESSAGE": "There was an error. Please try again."
}
}
}
},
"DELETE": {
"BUTTON_TEXT": "Delete",
"CONFIRM": {
"TITLE": "Confirm Deletion",
"MESSAGE": "Are you sure to delete?",
"YES": "Yes, Delete ",
"NO": "No, Keep "
"SMS": {
"HEADER_TITLE": "SMS campaigns",
"NEW_CAMPAIGN": "Create campaign",
"EMPTY_STATE": {
"TITLE": "No SMS campaigns are available",
"SUBTITLE": "Launch an SMS campaign to reach your customers directly. Send offers or make announcements with ease. Click 'Create campaign' to get started."
},
"CARD": {
"STATUS": {
"COMPLETED": "Completed",
"SCHEDULED": "Scheduled"
},
"CAMPAIGN_DETAILS": {
"SENT_FROM": "Sent from",
"ON": "on"
}
},
"CREATE": {
"TITLE": "Create SMS campaign",
"CANCEL_BUTTON_TEXT": "Cancel",
"CREATE_BUTTON_TEXT": "Create",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Please enter the title of campaign",
"ERROR": "Title is required"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Please enter the message of campaign",
"ERROR": "Message is required"
},
"INBOX": {
"LABEL": "Select Inbox",
"PLACEHOLDER": "Select Inbox",
"ERROR": "Inbox is required"
},
"AUDIENCE": {
"LABEL": "Audience",
"PLACEHOLDER": "Select the customer labels",
"ERROR": "Audience is required"
},
"SCHEDULED_AT": {
"LABEL": "Scheduled time",
"PLACEHOLDER": "Please select the time",
"ERROR": "Scheduled time is required"
},
"BUTTONS": {
"CREATE": "Create",
"CANCEL": "Cancel"
},
"API": {
"SUCCESS_MESSAGE": "SMS 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.",
"CONFIRM": "Delete",
"API": {
"SUCCESS_MESSAGE": "Campaign deleted successfully",
"ERROR_MESSAGE": "Could not delete the campaign. Please try again later."
"ERROR_MESSAGE": "There was an error. Please try again."
}
},
"EDIT": {
"TITLE": "Edit campaign",
"UPDATE_BUTTON_TEXT": "Update",
"API": {
"SUCCESS_MESSAGE": "Campaign updated successfully",
"ERROR_MESSAGE": "There was an error, please try again"
}
},
"LIST": {
"LOADING_MESSAGE": "Loading campaigns...",
"404": "There are no campaigns created for this inbox.",
"TABLE_HEADER": {
"TITLE": "Title",
"MESSAGE": "Message",
"INBOX": "Inbox",
"STATUS": "Status",
"SENDER": "Sender",
"URL": "URL",
"SCHEDULED_AT": "Scheduled time",
"TIME_ON_PAGE": "Time(Seconds)",
"CREATED_AT": "Created at"
},
"BUTTONS": {
"ADD": "Add",
"EDIT": "Edit",
"DELETE": "Delete"
},
"STATUS": {
"ENABLED": "Enabled",
"DISABLED": "Disabled",
"COMPLETED": "Completed",
"ACTIVE": "Active"
},
"SENDER": {
"BOT": "Bot"
}
},
"ONE_OFF": {
"HEADER": "One off campaigns",
"404": "There are no one off campaigns created",
"INBOXES_NOT_FOUND": "Please create an sms inbox and start adding campaigns"
},
"ONGOING": {
"HEADER": "Ongoing campaigns",
"404": "There are no ongoing campaigns created",
"INBOXES_NOT_FOUND": "Please create an website inbox and start adding campaigns"
}
}
}

View File

@@ -267,6 +267,8 @@
"NEW_INBOX": "New inbox",
"REPORTS_CONVERSATION": "Conversations",
"CSAT": "CSAT",
"LIVE_CHAT": "Live Chat",
"SMS": "SMS",
"CAMPAIGNS": "Campaigns",
"ONGOING": "Ongoing",
"ONE_OFF": "One off",

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,

View File

@@ -17,9 +17,9 @@ export const getters = {
return _state.uiFlags;
},
getCampaigns: _state => campaignType => {
return _state.records.filter(
record => record.campaign_type === campaignType
);
return _state.records
.filter(record => record.campaign_type === campaignType)
.sort((a1, a2) => a1.id - a2.id);
},
getAllCampaigns: _state => {
return _state.records;

View File

@@ -1,6 +1,6 @@
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
import * as types from '../mutation-types';
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import { INBOX_TYPES } from 'dashboard/helper/inbox';
import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';

View File

@@ -5,36 +5,8 @@ describe('#getters', () => {
it('get ongoing campaigns', () => {
const state = { records: campaigns };
expect(getters.getCampaigns(state)('ongoing')).toEqual([
{
id: 1,
title: 'Welcome',
description: null,
account_id: 1,
campaign_type: 'ongoing',
message: 'Hey, What brings you today',
enabled: true,
trigger_rules: {
url: 'https://github.com',
time_on_page: 10,
},
created_at: '2021-05-03T04:53:36.354Z',
updated_at: '2021-05-03T04:53:36.354Z',
},
{
id: 3,
title: 'Thanks',
description: null,
account_id: 1,
campaign_type: 'ongoing',
message: 'Thanks for coming to the show. How may I help you?',
enabled: false,
trigger_rules: {
url: 'https://noshow.com',
time_on_page: 10,
},
created_at: '2021-05-03T10:22:51.025Z',
updated_at: '2021-05-03T10:22:51.025Z',
},
campaigns[0],
campaigns[2],
]);
});

View File

@@ -1,47 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { useCampaign } from '../useCampaign';
import { useRoute } from 'vue-router';
import { CAMPAIGN_TYPES } from '../../constants/campaign';
// Mock the useRoute composable
vi.mock('vue-router', () => ({
useRoute: vi.fn(),
}));
describe('useCampaign', () => {
it('returns the correct campaign type for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONGOING);
});
it('returns the correct campaign type for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONE_OFF);
});
it('isOngoingType returns true for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(true);
});
it('isOngoingType returns false for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(false);
});
it('isOneOffType returns true for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(true);
});
it('isOneOffType returns false for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(false);
});
});

View File

@@ -1,43 +0,0 @@
import { computed } from 'vue';
import { useRoute } from 'vue-router';
import { CAMPAIGN_TYPES } from '../constants/campaign';
/**
* Composable to manage campaign types.
*
* @returns {Object} - Computed properties for campaign type and its checks.
*/
export const useCampaign = () => {
const route = useRoute();
/**
* Computed property to determine the current campaign type based on the route name.
*/
const campaignType = computed(() => {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[route.name];
});
/**
* Computed property to check if the current campaign type is 'ongoing'.
*/
const isOngoingType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONGOING;
});
/**
* Computed property to check if the current campaign type is 'one-off'.
*/
const isOneOffType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONE_OFF;
});
return {
campaignType,
isOngoingType,
isOneOffType,
};
};

View File

@@ -1,15 +1,4 @@
export const INBOX_TYPES = {
WEB: 'Channel::WebWidget',
FB: 'Channel::FacebookPage',
TWITTER: 'Channel::TwitterProfile',
TWILIO: 'Channel::TwilioSms',
WHATSAPP: 'Channel::Whatsapp',
API: 'Channel::Api',
EMAIL: 'Channel::Email',
TELEGRAM: 'Channel::Telegram',
LINE: 'Channel::Line',
SMS: 'Channel::Sms',
};
import { INBOX_TYPES } from 'dashboard/helper/inbox';
export const INBOX_FEATURES = {
REPLY_TO: 'replyTo',