feat: add basic ui

This commit is contained in:
Muhsin Keloth
2025-08-17 16:17:27 +05:30
parent 91d80004a6
commit dd2523d553
5 changed files with 611 additions and 0 deletions
@@ -0,0 +1,115 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'dashboard/composables/store';
const props = defineProps({
campaignId: {
type: Number,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const allCampaigns = computed(() => store.getters['campaigns/getAllCampaigns']);
const campaign = computed(() =>
allCampaigns.value.find(c => c.id === Number(props.campaignId))
);
const previewData = ref({
title: '',
message: '',
template: '',
audience: '',
campaignType: '',
});
const updatePreview = () => {
if (campaign.value) {
previewData.value = {
title: campaign.value.title || '',
message: campaign.value.message || '',
template: campaign.value.template || '',
audience: campaign.value.audience || '',
campaignType: campaign.value.campaign_type || '',
};
}
};
watch(
() => campaign.value,
() => updatePreview(),
{ immediate: true, deep: true }
);
</script>
<template>
<div class="p-4 h-full bg-white rounded-lg border">
<h3 class="mb-4 text-lg font-semibold">
{{ t('CAMPAIGN.PLAYGROUND.TITLE') }}
</h3>
<div class="space-y-4">
<!-- Campaign Title Preview -->
<div class="p-4 bg-gray-50 rounded-lg border">
<h4 class="mb-2 text-sm font-medium text-gray-700">
{{ t('CAMPAIGN.PLAYGROUND.CAMPAIGN_TITLE') }}
</h4>
<p class="text-sm text-gray-900">
{{ previewData.title || t('CAMPAIGN.PLAYGROUND.NO_TITLE') }}
</p>
</div>
<!-- Message Preview -->
<div class="p-4 bg-gray-50 rounded-lg border">
<h4 class="mb-2 text-sm font-medium text-gray-700">
{{ t('CAMPAIGN.PLAYGROUND.MESSAGE_PREVIEW') }}
</h4>
<div
class="text-sm text-gray-900 whitespace-pre-wrap"
v-html="previewData.message || t('CAMPAIGN.PLAYGROUND.NO_MESSAGE')"
/>
</div>
<!-- Template Info -->
<div v-if="previewData.template" class="p-4 bg-blue-50 rounded-lg border">
<h4 class="mb-2 text-sm font-medium text-blue-700">
{{ t('CAMPAIGN.PLAYGROUND.TEMPLATE') }}
</h4>
<p class="text-sm text-blue-900">
{{ previewData.template }}
</p>
</div>
<!-- Audience Info -->
<div
v-if="previewData.audience"
class="p-4 bg-green-50 rounded-lg border"
>
<h4 class="mb-2 text-sm font-medium text-green-700">
{{ t('CAMPAIGN.PLAYGROUND.AUDIENCE') }}
</h4>
<p class="text-sm text-green-900">
{{ previewData.audience }}
</p>
</div>
<!-- Campaign Type -->
<div class="p-4 bg-yellow-50 rounded-lg border">
<h4 class="mb-2 text-sm font-medium text-yellow-700">
{{ t('CAMPAIGN.PLAYGROUND.CAMPAIGN_TYPE') }}
</h4>
<p class="text-sm text-yellow-900">
{{ previewData.campaignType || t('CAMPAIGN.PLAYGROUND.NO_TYPE') }}
</p>
</div>
<!-- Preview Note -->
<div class="mt-4 text-xs italic text-gray-500">
{{ t('CAMPAIGN.PLAYGROUND.PREVIEW_NOTE') }}
</div>
</div>
</div>
</template>
@@ -0,0 +1,328 @@
<script setup>
import { reactive, computed, watch, ref } 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 Button from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import Accordion from 'dashboard/components-next/Accordion/Accordion.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
const props = defineProps({
mode: {
type: String,
required: true,
validator: value => ['edit', 'create'].includes(value),
},
campaign: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['submit']);
const { t } = useI18n();
const formState = {
uiFlags: useMapGetter('campaigns/getUIFlags'),
labels: useMapGetter('labels/getLabels'),
getFilteredWhatsAppTemplates: useMapGetter(
'inboxes/getFilteredWhatsAppTemplates'
),
};
const initialState = {
title: '',
description: '',
message: '',
templateId: null,
selectedAudience: [],
scheduledAt: null,
campaignType: 'ongoing',
};
const state = reactive({ ...initialState });
const templateParserRef = ref(null);
const validationRules = {
title: { required, minLength: minLength(1) },
templateId: { required },
selectedAudience: { required },
};
const v$ = useVuelidate(validationRules, state);
const isLoading = computed(() => formState.uiFlags.value.creatingItem);
const getErrorMessage = field => {
return v$.value[field].$error ? v$.value[field].$errors[0].$message : '';
};
const formErrors = computed(() => ({
title: getErrorMessage('title'),
template: getErrorMessage('templateId'),
audience: getErrorMessage('selectedAudience'),
}));
const templateOptions = computed(() => {
if (!props.campaign.inbox.id) return [];
const templates = formState.getFilteredWhatsAppTemplates.value(
props.campaign.inbox.id
);
return templates.map(template => {
const friendlyName = template.name
.replace(/_/g, ' ')
.replace(/\b\w/g, l => l.toUpperCase());
return {
value: template.id,
label: `${friendlyName} (${template.language || 'en'})`,
template: template,
};
});
});
const selectedTemplate = computed(() => {
if (!state.templateId) return null;
return templateOptions.value.find(option => option.value === state.templateId)
?.template;
});
const audienceList = computed(() => {
return (
formState.labels.value?.map(label => ({
value: label.id,
label: label.title,
})) ?? []
);
});
const updateStateFromCampaign = campaign => {
state.title = campaign.title || '';
state.description = campaign.description || '';
state.message = campaign.message || '';
state.templateId = campaign.template_params?.name
? templateOptions.value.find(
opt => opt.template.name === campaign.template_params.name
)?.value || null
: null;
state.selectedAudience = campaign.audience?.map(aud => aud.id) || [];
state.scheduledAt = campaign.scheduled_at || null;
state.campaignType = campaign.campaign_type || 'ongoing';
};
const handleBasicDetailsUpdate = async () => {
const result = await Promise.all([v$.value.title.$validate()]).then(results =>
results.every(Boolean)
);
if (!result) return;
const payload = {
title: state.title,
description: state.description,
};
emit('submit', payload);
};
const handleSelectTemplateUpdate = async () => {
const result = await v$.value.templateId.$validate();
if (!result) return;
const currentTemplate = selectedTemplate.value;
const parserData = templateParserRef.value;
const templateContent = parserData?.renderedTemplate || '';
const templateParams = {
name: currentTemplate?.name || '',
namespace: currentTemplate?.namespace || '',
category: currentTemplate?.category || 'UTILITY',
language: currentTemplate?.language || 'en_US',
processed_params: parserData?.processedParams || {},
};
const payload = {
message: templateContent,
template_params: templateParams,
};
emit('submit', payload);
};
const handleSelectAudienceUpdate = async () => {
const result = await v$.value.selectedAudience.$validate();
if (!result) return;
const payload = {
audience: state.selectedAudience?.map(id => ({
id,
type: 'Label',
})),
};
emit('submit', payload);
};
const handleScheduleTemplateUpdate = () => {
const payload = {
scheduled_at: state.scheduledAt,
campaign_type: state.campaignType,
};
emit('submit', payload);
};
watch(
() => props.campaign,
newCampaign => {
if (props.mode === 'edit' && newCampaign) {
updateStateFromCampaign(newCampaign);
}
},
{ immediate: true }
);
</script>
<template>
<form class="flex flex-col gap-4" @submit.prevent="handleSubmit">
<!-- Basic Details Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.BASIC_DETAILS')" is-open>
<div class="flex flex-col gap-4 pt-4">
<Input
v-model="state.title"
:label="t('CAMPAIGN.FORM.TITLE.LABEL')"
:placeholder="t('CAMPAIGN.FORM.TITLE.PLACEHOLDER')"
:message="formErrors.title"
:message-type="formErrors.title ? 'error' : 'info'"
/>
<Editor
v-model="state.description"
:label="t('CAMPAIGN.FORM.DESCRIPTION.LABEL')"
:placeholder="t('CAMPAIGN.FORM.DESCRIPTION.PLACEHOLDER')"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleBasicDetailsUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Select Template Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.SELECT_TEMPLATE')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-1">
<label
for="template"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ t('CAMPAIGN.FORM.TEMPLATE.LABEL') }}
</label>
<ComboBox
id="template"
v-model="state.templateId"
:options="templateOptions"
:has-error="!!formErrors.template"
:placeholder="t('CAMPAIGN.FORM.TEMPLATE.SELECT')"
:message="formErrors.template"
class="[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:dark:outline-n-weak [&>div>button:not(.focused)]:hover:!outline-n-slate-6"
/>
<p class="mt-1 text-xs text-n-slate-11">
{{ t('CAMPAIGN.FORM.TEMPLATE.INFO') }}
</p>
<div class="mt-4 text-xs italic text-gray-500">
{{ t('CAMPAIGN.PLAYGROUND.PREVIEW_NOTE') }}
</div>
</div>
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
/>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleSelectTemplateUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Select Audience Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.SELECT_AUDIENCE')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-1">
<label
for="audience"
class="mb-0.5 text-sm font-medium text-n-slate-12"
>
{{ t('CAMPAIGN.FORM.AUDIENCE.LABEL') }}
</label>
<TagMultiSelectComboBox
v-model="state.selectedAudience"
:options="audienceList"
:label="t('CAMPAIGN.FORM.AUDIENCE.LABEL')"
:placeholder="t('CAMPAIGN.FORM.AUDIENCE.SELECT')"
:has-error="!!formErrors.audience"
:message="formErrors.audience"
class="[&>div>button]:bg-n-alpha-black2"
/>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleSelectAudienceUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Schedule Template Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.SCHEDULE_TEMPLATE')">
<div class="flex flex-col gap-4 pt-4">
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.SCHEDULED_AT.LABEL') }}
</label>
<input
v-model="state.scheduledAt"
type="datetime-local"
class="p-2 w-full rounded-md border"
/>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleScheduleTemplateUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
</form>
</template>
@@ -201,6 +201,61 @@
}
}
},
"EDIT": {
"SUCCESS_MESSAGE": "Campaign updated successfully",
"ERROR_MESSAGE": "There was an error. Please try again.",
"NOT_FOUND": "Campaign not found"
},
"FORM": {
"SECTIONS": {
"BASIC_DETAILS": "Basic Details",
"SELECT_TEMPLATE": "Select Template",
"SELECT_AUDIENCE": "Select Audience",
"SCHEDULE_TEMPLATE": "Schedule Template"
},
"TITLE": {
"LABEL": "Campaign Title",
"PLACEHOLDER": "Enter campaign title"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter campaign description"
},
"MESSAGE": {
"LABEL": "Message",
"PLACEHOLDER": "Enter your campaign message"
},
"TEMPLATE": {
"LABEL": "Template",
"SELECT": "Select a template",
"INFO": "Select a template to use for this campaign."
},
"AUDIENCE": {
"LABEL": "Audience",
"SELECT": "Select audience"
},
"CAMPAIGN_TYPE": {
"LABEL": "Campaign Type",
"ONGOING": "Ongoing",
"ONE_OFF": "One-off"
},
"SCHEDULED_AT": {
"LABEL": "Schedule Time"
},
"UPDATE": "Update"
},
"PLAYGROUND": {
"TITLE": "Template Preview",
"CAMPAIGN_TITLE": "Campaign Title",
"MESSAGE_PREVIEW": "Message Preview",
"TEMPLATE": "Template",
"AUDIENCE": "Audience",
"CAMPAIGN_TYPE": "Campaign Type",
"NO_TITLE": "No title set",
"NO_MESSAGE": "No message set",
"NO_TYPE": "No type set",
"PREVIEW_NOTE": "This is a preview of how your campaign will appear to recipients."
},
"CONFIRM_DELETE": {
"TITLE": "Are you sure to delete?",
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
@@ -4,6 +4,7 @@ import CampaignsPageRouteView from './pages/CampaignsPageRouteView.vue';
import LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
import CampaignEdit from './pages/CampaignEdit.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const meta = {
@@ -62,6 +63,17 @@ const campaignsRoutes = {
},
],
},
{
path: frontendURL(
'accounts/:accountId/campaigns/whatsapp/:campaignId/edit'
),
name: 'campaigns_whatsapp_edit',
meta: {
...meta,
featureFlag: FEATURE_FLAGS.WHATSAPP_CAMPAIGNS,
},
component: CampaignEdit,
},
],
};
@@ -0,0 +1,101 @@
<script setup>
import { computed, onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import EditCampaignForm from 'dashboard/components-next/captain/pageComponents/campaign/EditCampaignForm.vue';
import CampaignPlayground from 'dashboard/components-next/captain/campaign/CampaignPlayground.vue';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const campaignId = route.params.campaignId;
const uiFlags = useMapGetter('campaigns/getUIFlags');
const isFetching = computed(() => uiFlags.value.fetchingItem);
const allCampaigns = computed(() => store.getters['campaigns/getAllCampaigns']);
const campaign = computed(() =>
allCampaigns.value.find(c => c.id === Number(campaignId))
);
const isCampaignAvailable = computed(() => !!campaign.value?.id);
const campaignStatus = computed(() => {
if (!campaign.value) return '';
// For WhatsApp campaigns, check if it's completed or scheduled
const STATUS_COMPLETED = 'completed';
return campaign.value.campaign_status === STATUS_COMPLETED
? t('CAMPAIGN.WHATSAPP.CARD.STATUS.COMPLETED')
: t('CAMPAIGN.WHATSAPP.CARD.STATUS.SCHEDULED');
});
const statusTextColor = computed(() => {
if (!campaign.value) return 'text-n-slate-12';
const STATUS_COMPLETED = 'completed';
const isActive = campaign.value.campaign_status !== STATUS_COMPLETED;
return !isActive ? 'text-n-teal-11' : 'text-n-slate-12';
});
const handleSubmit = async updatedCampaign => {
try {
await store.dispatch('campaigns/update', {
id: campaignId,
...updatedCampaign,
});
useAlert(t('CAMPAIGN.EDIT.SUCCESS_MESSAGE'));
} catch (error) {
const errorMessage = error?.message || t('CAMPAIGN.EDIT.ERROR_MESSAGE');
useAlert(errorMessage);
}
};
onMounted(() => {
if (!isCampaignAvailable.value) {
store.dispatch('campaigns/get');
}
});
</script>
<template>
<PageLayout
:show-pagination-footer="false"
:is-fetching="isFetching"
:show-know-more="false"
:back-url="{ name: 'campaigns_ongoing_index' }"
>
<template #headerTitle>
<div class="flex gap-2 items-center">
<span>{{ campaign?.title }}</span>
<span
class="inline-flex items-center px-2 py-0.5 h-6 text-xs font-medium rounded-md bg-n-alpha-2"
:class="statusTextColor"
>
{{ campaignStatus }}
</span>
</div>
</template>
<template #body>
<div v-if="!isCampaignAvailable">
{{ t('CAMPAIGN.EDIT.NOT_FOUND') }}
</div>
<div v-else class="flex gap-4 h-full">
<div class="flex-1 pr-4 h-full lg:overflow-auto md:h-auto">
<EditCampaignForm
:campaign="campaign"
mode="edit"
@submit="handleSubmit"
/>
</div>
<div class="w-[400px] hidden lg:block h-full">
<CampaignPlayground :campaign-id="Number(campaignId)" />
</div>
</div>
</template>
</PageLayout>
</template>