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": {
|
"CONFIRM_DELETE": {
|
||||||
"TITLE": "Are you sure to delete?",
|
"TITLE": "Are you sure to delete?",
|
||||||
"DESCRIPTION": "The delete action is permanent and cannot be reversed.",
|
"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 LiveChatCampaignsPage from './pages/LiveChatCampaignsPage.vue';
|
||||||
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
import SMSCampaignsPage from './pages/SMSCampaignsPage.vue';
|
||||||
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
import WhatsAppCampaignsPage from './pages/WhatsAppCampaignsPage.vue';
|
||||||
|
import CampaignEdit from './pages/CampaignEdit.vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
|
|
||||||
const meta = {
|
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