Compare commits

...
Author SHA1 Message Date
Muhsin Keloth 1df19772a4 chore: make campaign ui better 2025-08-17 18:10:56 +05:30
Muhsin Keloth 090d066f9f chore: add playground 2025-08-17 17:55:56 +05:30
Muhsin Keloth 51d0acb90c chore: account health 2025-08-17 17:32:24 +05:30
Muhsin Keloth e5104e0b5e Update EditCampaignForm.vue 2025-08-17 16:39:51 +05:30
Muhsin Keloth e22397f8af chore: add contacts count section 2025-08-17 16:36:29 +05:30
Muhsin Keloth dd2523d553 feat: add basic ui 2025-08-17 16:17:27 +05:30
5 changed files with 942 additions and 0 deletions
@@ -0,0 +1,237 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
selectedTemplate: {
type: Object,
default: null,
},
templateVariables: {
type: Object,
default: () => ({}),
},
});
const { t } = useI18n();
// Template data based on templates.json examples
const templateExamples = {
training_video: {
header: { type: 'VIDEO', content: '🎥 Video Header' },
body: "Hi {{name}}, here's your training video. Please watch by {{date}}.",
variables: { name: 'John', date: ' July 31' },
},
discount_coupon: {
body: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
buttons: [{ type: 'COPY_CODE', text: 'Copy offer code' }],
variables: { discount_percentage: '30' },
},
support_callback: {
body: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
buttons: [{ type: 'PHONE_NUMBER', text: 'Call Support' }],
variables: { name: 'muhsin', ticket_id: '232323' },
},
feedback_request: {
body: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
buttons: [{ type: 'URL', text: 'Leave Feedback' }],
variables: { name: 'muhsin' },
},
order_confirmation: {
header: { type: 'IMAGE', content: '🖼️ Product Image' },
body: 'Welcome to our Diwali sale! Get flat 50% off on select items. Hurry now!',
},
technician_visit: {
header: { type: 'TEXT', content: 'Technician visit' },
body: "Hi {{1}}, we're scheduling a technician visit to {{2}} on {{3}} between {{4}} and {{5}}. Please confirm if this time slot works for you.",
buttons: [
{ type: 'QUICK_REPLY', text: 'Confirm' },
{ type: 'QUICK_REPLY', text: 'Reschedule' },
],
variables: {
1: 'John',
2: '123 Maple St',
3: '2025-12-31',
4: '10:00 AM',
5: '2:00 PM',
},
},
};
const getTemplatePreview = () => {
// If no template selected, return null to show empty state
if (!props.selectedTemplate) {
return null;
}
const templateName = props.selectedTemplate.name;
const templateExample = templateExamples[templateName];
if (templateExample) {
let processedBody = templateExample.body;
// Use actual template variables if provided, otherwise fall back to examples
const variablesToUse =
Object.keys(props.templateVariables).length > 0
? props.templateVariables
: templateExample.variables;
// Replace variables with actual or example values
if (variablesToUse) {
Object.entries(variablesToUse).forEach(([key, value]) => {
// Handle both positional ({{1}}) and named ({{name}}) variables
const pattern = `{{${key}}}`;
processedBody = processedBody.replace(
new RegExp(pattern.replace(/[{}]/g, '\\$&'), 'g'),
value || `{{${key}}}`
);
});
}
return {
header: templateExample.header,
body: processedBody,
buttons: templateExample.buttons,
};
}
// Fallback for templates not in our examples - use raw template data
if (props.selectedTemplate.components) {
const components = props.selectedTemplate.components;
const headerComponent = components.find(c => c.type === 'HEADER');
const bodyComponent = components.find(c => c.type === 'BODY');
const buttonComponent = components.find(c => c.type === 'BUTTONS');
let processedBody = bodyComponent
? bodyComponent.text || 'Message content'
: 'No message content';
// Replace variables in body text with actual values
if (
props.templateVariables &&
Object.keys(props.templateVariables).length > 0
) {
Object.entries(props.templateVariables).forEach(([key, value]) => {
const pattern = `{{${key}}}`;
processedBody = processedBody.replace(
new RegExp(pattern.replace(/[{}]/g, '\\$&'), 'g'),
value || `{{${key}}}`
);
});
}
return {
header: headerComponent
? {
type: headerComponent.format || 'TEXT',
content: headerComponent.text || 'Header',
}
: null,
body: processedBody,
buttons: buttonComponent ? buttonComponent.buttons : null,
};
}
return null;
};
const templatePreview = computed(() => getTemplatePreview());
</script>
<template>
<div class="h-full bg-white rounded-lg border border-n-slate-4">
<!-- Header -->
<div class="p-4 border-b border-n-slate-4">
<h3 class="text-lg font-semibold text-n-slate-12">
{{ t('CAMPAIGN.PLAYGROUND.TITLE') }}
</h3>
</div>
<!-- WhatsApp Preview Container -->
<div class="flex flex-col p-4 h-96">
<!-- Empty State (No Template Selected) -->
<div
v-if="!templatePreview"
class="flex flex-col flex-1 justify-center items-center"
>
<div class="text-center">
<h4 class="mb-1 text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.PLAYGROUND.EMPTY_STATE.TITLE') }}
</h4>
<p class="text-xs text-n-slate-11">
{{ t('CAMPAIGN.PLAYGROUND.EMPTY_STATE.SUBTITLE') }}
</p>
</div>
</div>
<!-- Template Preview (Template Selected) -->
<div v-else>
<!-- WhatsApp-style Message Bubble -->
<div class="flex justify-center">
<div
class="p-3 rounded-lg border bg-n-slate-2 text-n-slate-12 border-n-slate-4"
>
<!-- Header Media/Text (if exists) -->
<div v-if="templatePreview.header" class="mb-2">
<div
v-if="templatePreview.header.type === 'IMAGE'"
class="p-2 -m-3 mb-2 text-xs text-center rounded-t-lg bg-n-slate-3"
>
{{ templatePreview.header.content }}
</div>
<div
v-else-if="templatePreview.header.type === 'VIDEO'"
class="p-2 -m-3 mb-2 text-xs text-center rounded-t-lg bg-n-slate-3"
>
{{ templatePreview.header.content }}
</div>
<div
v-else-if="templatePreview.header.type === 'TEXT'"
class="mb-1 text-sm font-semibold"
>
{{ templatePreview.header.content }}
</div>
</div>
<!-- Message Body -->
<div class="text-sm leading-relaxed whitespace-pre-wrap">
{{ templatePreview.body }}
</div>
<!-- Action Buttons -->
<div v-if="templatePreview.buttons" class="-m-3 mt-2 mt-3">
<div class="pt-2 border-t border-n-slate-4">
<div
v-for="(button, index) in templatePreview.buttons"
:key="index"
class="py-2 text-sm font-medium text-center border-b transition-colors cursor-pointer border-n-slate-4 last:border-b-0 hover:bg-n-slate-3 text-n-slate-12"
>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<span v-if="button.type === 'URL'">🔗</span>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<span v-else-if="button.type === 'PHONE_NUMBER'">📞</span>
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<span v-else-if="button.type === 'COPY_CODE'">📋</span>
{{ button.text }}
</div>
</div>
</div>
</div>
</div>
<!-- Spacer -->
<div class="flex-1 min-h-4" />
<!-- Preview Note -->
<div class="p-3 rounded-lg bg-n-alpha-2">
<!-- eslint-disable-next-line vue/no-bare-strings-in-template, @intlify/vue-i18n/no-raw-text -->
<p class="text-xs leading-relaxed text-center text-n-slate-11">
This is a preview of how your WhatsApp message will appear to
recipients and may vary slightly depending on the device. Perform a
test send to confirm the final appearance.
</p>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,496 @@
<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', 'templateChange', 'variablesChange']);
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 templateVariables = ref({});
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 selectedLabels = computed(() => {
if (!state.selectedAudience?.length) return [];
return (
formState.labels.value?.filter(label =>
state.selectedAudience.includes(label.id)
) || []
);
});
const totalContactsForSelectedLabels = computed(() => {
// Mock calculation - in real implementation this would come from the API
return selectedLabels.value.reduce((total, label) => {
// Mock contact counts for demonstration
const mockCounts = {
premium: 909,
billing: 1000,
};
const labelName = label.title.toLowerCase();
return (
total + (mockCounts[labelName] || Math.floor(Math.random() * 500) + 100)
);
}, 0);
});
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);
};
const handleViewContacts = label => {
// Mock function - in real implementation this would navigate to contacts filtered by label
// eslint-disable-next-line no-console
console.log(`Viewing contacts for label: ${label.title}`);
// In real implementation: router.push({ name: 'contacts', query: { label: label.id } });
};
const handleVariablesUpdate = variables => {
templateVariables.value = variables || {};
emit('variablesChange', templateVariables.value);
};
watch(
() => props.campaign,
newCampaign => {
if (props.mode === 'edit' && newCampaign) {
updateStateFromCampaign(newCampaign);
}
},
{ immediate: true }
);
watch(
() => selectedTemplate.value,
newTemplate => {
emit('templateChange', newTemplate);
},
{ immediate: true }
);
watch(
() => templateVariables.value,
newVariables => {
emit('variablesChange', newVariables);
},
{ deep: 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>
<!-- Template Parser -->
<WhatsAppTemplateParser
v-if="selectedTemplate"
ref="templateParserRef"
:template="selectedTemplate"
@variables-update="handleVariablesUpdate"
/>
<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>
<!-- Contact Count Information -->
<div v-if="selectedLabels.length > 0" class="flex flex-col gap-2 mt-2">
<!-- Individual label contact counts -->
<div class="space-y-1">
<div
v-for="label in selectedLabels"
:key="label.id"
class="flex gap-2 items-center text-sm text-n-slate-11"
>
<div>
<span class="font-medium">
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.LABEL_PREFIX')
}}{{ label.title.toLowerCase() }}
</span>
<span>
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.HAS_AROUND') }}
<!-- eslint-disable-next-line @intlify/vue-i18n/no-raw-text -->
{{ label.title.toLowerCase() === 'premium' ? '909' : '1000' }}
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.CONTACTS_TAGGED') }}
</span>
</div>
<Button
:label="t('CAMPAIGN.FORM.CONTACT_COUNT.VIEW_CONTACTS')"
variant="link"
size="xs"
@click="handleViewContacts(label)"
/>
</div>
</div>
<!-- Total contact count -->
<div class="p-3 mt-2 rounded-md bg-n-alpha-2">
<p class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.SENDING_TO') }}
{{ totalContactsForSelectedLabels }}
{{ t('CAMPAIGN.FORM.CONTACT_COUNT.CONTACTS_TOTAL') }}
</p>
</div>
</div>
<div class="flex justify-end">
<Button
size="small"
:loading="isLoading"
@click="handleSelectAudienceUpdate"
>
{{ t('CAMPAIGN.FORM.UPDATE') }}
</Button>
</div>
</div>
</Accordion>
<!-- Account Health Section -->
<Accordion :title="t('CAMPAIGN.FORM.SECTIONS.ACCOUNT_HEALTH')">
<div class="flex flex-col gap-6 pt-4">
<!-- Messaging Tier Card -->
<div class="p-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<div class="flex items-start justify-between">
<div class="flex items-center gap-2">
<span class="i-lucide-gauge text-lg text-n-slate-10" />
<div>
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.MESSAGING_TIER') }}
</h4>
<p class="text-xs text-n-slate-11 mt-1">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.TIER_DESCRIPTION') }}
</p>
</div>
</div>
<a
href="https://www.zoko.io/learning-article/whatsapp-business-api-messaging-limits-and-how-to-upgrade-to-the-next-tier"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-1 text-n-blue-11 hover:text-n-blue-12 text-xs underline"
>
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.LEARN_MORE') }}
<span class="i-lucide-external-link text-xs" />
</a>
</div>
</div>
<!-- Account Health Status -->
<div class="p-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="i-lucide-heart-pulse text-lg text-green-600" />
<div>
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.ACCOUNT_HEALTH') }}
</h4>
<p class="text-xs text-n-slate-11 mt-1">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.QUALITY_RATING') }}
</p>
</div>
</div>
<span
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-green-600 rounded-full"
>
<span class="w-1.5 h-1.5 bg-white rounded-full mr-1.5" />
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.HEALTH_GREEN') }}
</span>
</div>
</div>
<!-- Account Status -->
<div class="p-4 border rounded-lg bg-n-alpha-1 border-n-weak">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="i-lucide-wifi text-lg text-blue-600" />
<div>
<h4 class="text-sm font-medium text-n-slate-12">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.ACCOUNT_STATUS') }}
</h4>
<p class="text-xs text-n-slate-11 mt-1">
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.API_CONNECTION') }}
</p>
</div>
</div>
<span
class="inline-flex items-center px-3 py-1.5 text-xs font-medium text-blue-700 bg-blue-100 rounded-full"
>
<span class="w-1.5 h-1.5 bg-blue-600 rounded-full mr-1.5" />
{{ t('CAMPAIGN.FORM.ACCOUNT_HEALTH.STATUS_CONNECTED') }}
</span>
</div>
</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,87 @@
}
}
},
"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",
"ACCOUNT_HEALTH": "Account Health",
"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",
"CONTACT_COUNT": {
"LABEL_PREFIX": "#",
"HAS_AROUND": "has around",
"CONTACTS_TAGGED": "contacts tagged",
"VIEW_CONTACTS": "view contacts",
"SENDING_TO": "You are sending this campaign to",
"CONTACTS_TOTAL": "contacts"
},
"ACCOUNT_HEALTH": {
"MESSAGING_TIER": "Messaging Tier:",
"ACCOUNT_HEALTH": "Account Health:",
"ACCOUNT_STATUS": "Account Status:",
"TIER_DESCRIPTION": "TIER 3 (MAX 100,000 CUSTOMERS IN 24 HOURS)",
"STATUS_CONNECTED": "CONNECTED",
"HEALTH_GREEN": "GREEN",
"HEALTH_YELLOW": "YELLOW",
"HEALTH_RED": "RED",
"LEARN_MORE": "Learn more about Tiers",
"QUALITY_RATING": "Quality rating",
"API_CONNECTION": "API connection"
}
},
"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.",
"EMPTY_STATE": {
"TITLE": "Select a template to preview",
"SUBTITLE": "Choose a WhatsApp template from the 'Select Template' section above to see how your WhatsApp message 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,116 @@
<script setup>
import { computed, onMounted, ref } 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 selectedTemplate = ref(null);
const templateVariables = ref({});
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);
}
};
const handleTemplateChange = template => {
selectedTemplate.value = template;
};
const handleVariablesChange = variables => {
templateVariables.value = variables;
};
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"
@template-change="handleTemplateChange"
@variables-change="handleVariablesChange"
/>
</div>
<div class="w-[400px] hidden lg:block h-full">
<CampaignPlayground
:selected-template="selectedTemplate"
:template-variables="templateVariables"
/>
</div>
</div>
</template>
</PageLayout>
</template>