Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1df19772a4 | ||
|
|
090d066f9f | ||
|
|
51d0acb90c | ||
|
|
e5104e0b5e | ||
|
|
e22397f8af | ||
|
|
dd2523d553 |
@@ -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>
|
||||
+496
@@ -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>
|
||||
Reference in New Issue
Block a user