feat: WhatsApp enhanced templates front end changes (#12117)
Part of the https://github.com/chatwoot/chatwoot/pull/11997 Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
parent
dbb164a37d
commit
5c560c7628
@ -9,6 +9,7 @@ import Input from 'dashboard/components-next/input/Input.vue';
|
|||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
|
||||||
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
import TagMultiSelectComboBox from 'dashboard/components-next/combobox/TagMultiSelectComboBox.vue';
|
||||||
|
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||||
|
|
||||||
const emit = defineEmits(['submit', 'cancel']);
|
const emit = defineEmits(['submit', 'cancel']);
|
||||||
|
|
||||||
@ -18,7 +19,9 @@ const formState = {
|
|||||||
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
uiFlags: useMapGetter('campaigns/getUIFlags'),
|
||||||
labels: useMapGetter('labels/getLabels'),
|
labels: useMapGetter('labels/getLabels'),
|
||||||
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
inboxes: useMapGetter('inboxes/getWhatsAppInboxes'),
|
||||||
getWhatsAppTemplates: useMapGetter('inboxes/getWhatsAppTemplates'),
|
getFilteredWhatsAppTemplates: useMapGetter(
|
||||||
|
'inboxes/getFilteredWhatsAppTemplates'
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
@ -30,7 +33,7 @@ const initialState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const state = reactive({ ...initialState });
|
const state = reactive({ ...initialState });
|
||||||
const processedParams = ref({});
|
const templateParserRef = ref(null);
|
||||||
|
|
||||||
const rules = {
|
const rules = {
|
||||||
title: { required, minLength: minLength(1) },
|
title: { required, minLength: minLength(1) },
|
||||||
@ -67,7 +70,7 @@ const inboxOptions = computed(() =>
|
|||||||
|
|
||||||
const templateOptions = computed(() => {
|
const templateOptions = computed(() => {
|
||||||
if (!state.inboxId) return [];
|
if (!state.inboxId) return [];
|
||||||
const templates = formState.getWhatsAppTemplates.value(state.inboxId);
|
const templates = formState.getFilteredWhatsAppTemplates.value(state.inboxId);
|
||||||
return templates.map(template => {
|
return templates.map(template => {
|
||||||
// Create a more user-friendly label from template name
|
// Create a more user-friendly label from template name
|
||||||
const friendlyName = template.name
|
const friendlyName = template.name
|
||||||
@ -88,26 +91,6 @@ const selectedTemplate = computed(() => {
|
|||||||
?.template;
|
?.template;
|
||||||
});
|
});
|
||||||
|
|
||||||
const templateString = computed(() => {
|
|
||||||
if (!selectedTemplate.value) return '';
|
|
||||||
try {
|
|
||||||
return (
|
|
||||||
selectedTemplate.value.components?.find(
|
|
||||||
component => component.type === 'BODY'
|
|
||||||
)?.text || ''
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedString = computed(() => {
|
|
||||||
if (!templateString.value) return '';
|
|
||||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
|
||||||
return processedParams.value[variable] || `{{${variable}}}`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const getErrorMessage = (field, errorKey) => {
|
const getErrorMessage = (field, errorKey) => {
|
||||||
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
const baseKey = 'CAMPAIGN.WHATSAPP.CREATE.FORM';
|
||||||
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
return v$.value[field].$error ? t(`${baseKey}.${errorKey}.ERROR`) : '';
|
||||||
@ -122,8 +105,7 @@ const formErrors = computed(() => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const hasRequiredTemplateParams = computed(() => {
|
const hasRequiredTemplateParams = computed(() => {
|
||||||
const params = Object.values(processedParams.value);
|
return templateParserRef.value?.v$?.$invalid === false || true;
|
||||||
return params.length === 0 || params.every(param => param.trim() !== '');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const isSubmitDisabled = computed(
|
const isSubmitDisabled = computed(
|
||||||
@ -135,32 +117,18 @@ const formatToUTCString = localDateTime =>
|
|||||||
|
|
||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
Object.assign(state, initialState);
|
Object.assign(state, initialState);
|
||||||
processedParams.value = {};
|
|
||||||
v$.value.$reset();
|
v$.value.$reset();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => emit('cancel');
|
const handleCancel = () => emit('cancel');
|
||||||
|
|
||||||
const generateVariables = () => {
|
|
||||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
|
||||||
if (!matchedVariables) {
|
|
||||||
processedParams.value = {};
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalVars = matchedVariables.map(match => match.replace(/{{|}}/g, ''));
|
|
||||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
|
||||||
acc[variable] = processedParams.value[variable] || '';
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const prepareCampaignDetails = () => {
|
const prepareCampaignDetails = () => {
|
||||||
// Find the selected template to get its content
|
// Find the selected template to get its content
|
||||||
const currentTemplate = selectedTemplate.value;
|
const currentTemplate = selectedTemplate.value;
|
||||||
|
const parserData = templateParserRef.value;
|
||||||
|
|
||||||
// Extract template content - this should be the template message body
|
// Extract template content - this should be the template message body
|
||||||
const templateContent = templateString.value;
|
const templateContent = parserData?.renderedTemplate || '';
|
||||||
|
|
||||||
// Prepare template_params object with the same structure as used in contacts
|
// Prepare template_params object with the same structure as used in contacts
|
||||||
const templateParams = {
|
const templateParams = {
|
||||||
@ -168,7 +136,7 @@ const prepareCampaignDetails = () => {
|
|||||||
namespace: currentTemplate?.namespace || '',
|
namespace: currentTemplate?.namespace || '',
|
||||||
category: currentTemplate?.category || 'UTILITY',
|
category: currentTemplate?.category || 'UTILITY',
|
||||||
language: currentTemplate?.language || 'en_US',
|
language: currentTemplate?.language || 'en_US',
|
||||||
processed_params: processedParams.value,
|
processed_params: parserData?.processedParams || {},
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -198,15 +166,6 @@ watch(
|
|||||||
() => state.inboxId,
|
() => state.inboxId,
|
||||||
() => {
|
() => {
|
||||||
state.templateId = null;
|
state.templateId = null;
|
||||||
processedParams.value = {};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
// Generate variables when template changes
|
|
||||||
watch(
|
|
||||||
() => state.templateId,
|
|
||||||
() => {
|
|
||||||
generateVariables();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
</script>
|
</script>
|
||||||
@ -254,62 +213,12 @@ watch(
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Template Preview -->
|
<!-- Template Parser -->
|
||||||
<div
|
<WhatsAppTemplateParser
|
||||||
v-if="selectedTemplate"
|
v-if="selectedTemplate"
|
||||||
class="flex flex-col gap-4 p-4 rounded-lg bg-n-alpha-black2"
|
ref="templateParserRef"
|
||||||
>
|
:template="selectedTemplate"
|
||||||
<div class="flex justify-between items-center">
|
/>
|
||||||
<h3 class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ selectedTemplate.name }}
|
|
||||||
</h3>
|
|
||||||
<span class="text-xs text-n-slate-11">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.LANGUAGE') }}:
|
|
||||||
{{ selectedTemplate.language || 'en' }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div class="rounded-md bg-n-alpha-black3">
|
|
||||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
|
||||||
{{ processedString }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="text-xs text-n-slate-11">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.CATEGORY') }}:
|
|
||||||
{{ selectedTemplate.category || 'UTILITY' }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Template Variables -->
|
|
||||||
<div
|
|
||||||
v-if="Object.keys(processedParams).length > 0"
|
|
||||||
class="flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<label class="text-sm font-medium text-n-slate-12">
|
|
||||||
{{ t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLES_LABEL') }}
|
|
||||||
</label>
|
|
||||||
<div class="flex flex-col gap-2">
|
|
||||||
<div
|
|
||||||
v-for="(value, key) in processedParams"
|
|
||||||
:key="key"
|
|
||||||
class="flex gap-2 items-center"
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
v-model="processedParams[key]"
|
|
||||||
type="text"
|
|
||||||
class="flex-1"
|
|
||||||
:placeholder="
|
|
||||||
t('CAMPAIGN.WHATSAPP.CREATE.FORM.TEMPLATE.VARIABLE_PLACEHOLDER', {
|
|
||||||
variable: key,
|
|
||||||
})
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
<label for="audience" class="mb-0.5 text-sm font-medium text-n-slate-12">
|
||||||
|
|||||||
@ -25,6 +25,7 @@ const props = defineProps({
|
|||||||
hasNoInbox: { type: Boolean, default: false },
|
hasNoInbox: { type: Boolean, default: false },
|
||||||
isDropdownActive: { type: Boolean, default: false },
|
isDropdownActive: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
|
inboxId: { type: Number, default: null },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits([
|
const emit = defineEmits([
|
||||||
@ -150,9 +151,10 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
<div
|
<div
|
||||||
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
class="flex items-center justify-between w-full h-[3.25rem] gap-2 px-4 py-3"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex gap-2 items-center">
|
||||||
<WhatsAppOptions
|
<WhatsAppOptions
|
||||||
v-if="isWhatsappInbox"
|
v-if="isWhatsappInbox"
|
||||||
|
:inbox-id="inboxId"
|
||||||
:message-templates="messageTemplates"
|
:message-templates="messageTemplates"
|
||||||
@send-message="emit('sendWhatsappMessage', $event)"
|
@send-message="emit('sendWhatsappMessage', $event)"
|
||||||
/>
|
/>
|
||||||
@ -206,7 +208,7 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex gap-2 items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
:label="t('COMPOSE_NEW_CONVERSATION.FORM.ACTION_BUTTONS.DISCARD')"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
|
|||||||
@ -336,6 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
|||||||
:is-loading="isCreating"
|
:is-loading="isCreating"
|
||||||
:disable-send-button="isCreating"
|
:disable-send-button="isCreating"
|
||||||
:has-selected-inbox="!!targetInbox"
|
:has-selected-inbox="!!targetInbox"
|
||||||
|
:inbox-id="targetInbox?.id"
|
||||||
:has-no-inbox="showNoInboxAlert"
|
:has-no-inbox="showNoInboxAlert"
|
||||||
:is-dropdown-active="isAnyDropdownActive"
|
:is-dropdown-active="isAnyDropdownActive"
|
||||||
:message-signature="messageSignature"
|
:message-signature="messageSignature"
|
||||||
|
|||||||
@ -1,24 +1,25 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue';
|
import { computed, ref } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
|
import WhatsappTemplate from './WhatsappTemplate.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
messageTemplates: {
|
inboxId: {
|
||||||
type: Array,
|
type: Number,
|
||||||
default: () => [],
|
required: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['sendMessage']);
|
const emit = defineEmits(['sendMessage']);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
const getFilteredWhatsAppTemplates = useMapGetter(
|
||||||
// TODO: Remove this when we support all formats
|
'inboxes/getFilteredWhatsAppTemplates'
|
||||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
);
|
||||||
|
|
||||||
const searchQuery = ref('');
|
const searchQuery = ref('');
|
||||||
const selectedTemplate = ref(null);
|
const selectedTemplate = ref(null);
|
||||||
@ -26,19 +27,7 @@ const selectedTemplate = ref(null);
|
|||||||
const showTemplatesMenu = ref(false);
|
const showTemplatesMenu = ref(false);
|
||||||
|
|
||||||
const whatsAppTemplateMessages = computed(() => {
|
const whatsAppTemplateMessages = computed(() => {
|
||||||
// Add null check and ensure it's an array
|
return getFilteredWhatsAppTemplates.value(props.inboxId);
|
||||||
const templates = Array.isArray(props.messageTemplates)
|
|
||||||
? props.messageTemplates
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// TODO: Remove the last filter when we support all formats
|
|
||||||
return templates
|
|
||||||
.filter(template => template?.status?.toLowerCase() === 'approved')
|
|
||||||
.filter(template => {
|
|
||||||
return template?.components?.every(component => {
|
|
||||||
return !formatsToRemove.includes(component.format);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const filteredTemplates = computed(() => {
|
const filteredTemplates = computed(() => {
|
||||||
@ -106,7 +95,7 @@ const handleSendMessage = template => {
|
|||||||
<div
|
<div
|
||||||
v-for="template in filteredTemplates"
|
v-for="template in filteredTemplates"
|
||||||
:key="template.id"
|
:key="template.id"
|
||||||
class="flex flex-col w-full gap-2 p-2 rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||||
@click="handleTemplateClick(template)"
|
@click="handleTemplateClick(template)"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
<span class="text-sm text-n-slate-12">{{ template.name }}</span>
|
||||||
@ -115,12 +104,12 @@ const handleSendMessage = template => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="filteredTemplates.length === 0">
|
<template v-if="filteredTemplates.length === 0">
|
||||||
<p class="w-full pt-2 text-sm text-n-slate-11">
|
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
{{ t('COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.EMPTY_STATE') }}
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<WhatsappTemplateParser
|
<WhatsappTemplate
|
||||||
v-if="selectedTemplate"
|
v-if="selectedTemplate"
|
||||||
:template="selectedTemplate"
|
:template="selectedTemplate"
|
||||||
@send-message="handleSendMessage"
|
@send-message="handleSendMessage"
|
||||||
|
|||||||
@ -0,0 +1,64 @@
|
|||||||
|
<script setup>
|
||||||
|
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
template: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['sendMessage', 'back']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleSendMessage = payload => {
|
||||||
|
emit('sendMessage', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
emit('back');
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||||
|
>
|
||||||
|
<div class="w-full">
|
||||||
|
<WhatsAppTemplateParser
|
||||||
|
:template="template"
|
||||||
|
@send-message="handleSendMessage"
|
||||||
|
@back="handleBack"
|
||||||
|
>
|
||||||
|
<template #actions="{ sendMessage, goBack, disabled }">
|
||||||
|
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.BACK'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
color="slate"
|
||||||
|
variant="faded"
|
||||||
|
class="w-full font-medium"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="
|
||||||
|
t(
|
||||||
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.SEND_MESSAGE'
|
||||||
|
)
|
||||||
|
"
|
||||||
|
class="w-full font-medium"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="sendMessage"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</WhatsAppTemplateParser>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -0,0 +1,279 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue';
|
||||||
|
import { useVuelidate } from '@vuelidate/core';
|
||||||
|
import { requiredIf } from '@vuelidate/validators';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
import Input from 'dashboard/components-next/input/Input.vue';
|
||||||
|
import {
|
||||||
|
buildTemplateParameters,
|
||||||
|
allKeysRequired,
|
||||||
|
replaceTemplateVariables,
|
||||||
|
DEFAULT_LANGUAGE,
|
||||||
|
DEFAULT_CATEGORY,
|
||||||
|
COMPONENT_TYPES,
|
||||||
|
MEDIA_FORMATS,
|
||||||
|
findComponentByType,
|
||||||
|
} from 'dashboard/helper/templateHelper';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
template: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
validator: value => {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
if (!value.components || !Array.isArray(value.components)) return false;
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const processedParams = ref({});
|
||||||
|
|
||||||
|
const languageLabel = computed(() => {
|
||||||
|
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || DEFAULT_LANGUAGE}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryLabel = computed(() => {
|
||||||
|
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || DEFAULT_CATEGORY}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerComponent = computed(() => {
|
||||||
|
return findComponentByType(props.template, COMPONENT_TYPES.HEADER);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyComponent = computed(() => {
|
||||||
|
return findComponentByType(props.template, COMPONENT_TYPES.BODY);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyText = computed(() => {
|
||||||
|
return bodyComponent.value?.text || '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMediaHeader = computed(() =>
|
||||||
|
MEDIA_FORMATS.includes(headerComponent.value?.format)
|
||||||
|
);
|
||||||
|
|
||||||
|
const formatType = computed(() => {
|
||||||
|
const format = headerComponent.value?.format;
|
||||||
|
return format ? format.charAt(0) + format.slice(1).toLowerCase() : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasVariables = computed(() => {
|
||||||
|
return bodyText.value?.match(/{{([^}]+)}}/g) !== null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedTemplate = computed(() => {
|
||||||
|
return replaceTemplateVariables(bodyText.value, processedParams.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFormInvalid = computed(() => {
|
||||||
|
if (!hasVariables.value && !hasMediaHeader.value) return false;
|
||||||
|
|
||||||
|
if (hasMediaHeader.value && !processedParams.value.header?.media_url) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasVariables.value && processedParams.value.body) {
|
||||||
|
const hasEmptyBodyVariable = Object.values(processedParams.value.body).some(
|
||||||
|
value => !value
|
||||||
|
);
|
||||||
|
if (hasEmptyBodyVariable) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (processedParams.value.buttons) {
|
||||||
|
const hasEmptyButtonParameter = processedParams.value.buttons.some(
|
||||||
|
button => !button.parameter
|
||||||
|
);
|
||||||
|
if (hasEmptyButtonParameter) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
{
|
||||||
|
processedParams: {
|
||||||
|
requiredIfKeysPresent: requiredIf(hasVariables),
|
||||||
|
allKeysRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ processedParams }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeTemplateParameters = () => {
|
||||||
|
processedParams.value = buildTemplateParameters(
|
||||||
|
props.template,
|
||||||
|
hasMediaHeader.value
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMediaUrl = value => {
|
||||||
|
processedParams.value.header ??= {};
|
||||||
|
processedParams.value.header.media_url = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
|
||||||
|
const { name, category, language, namespace } = props.template;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: renderedTemplate.value,
|
||||||
|
templateParams: {
|
||||||
|
name,
|
||||||
|
category,
|
||||||
|
language,
|
||||||
|
namespace,
|
||||||
|
processed_params: processedParams.value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
emit('sendMessage', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTemplate = () => {
|
||||||
|
emit('resetTemplate');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
emit('back');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(initializeTemplateParameters);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.template,
|
||||||
|
() => {
|
||||||
|
initializeTemplateParameters();
|
||||||
|
v$.value.$reset();
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
processedParams,
|
||||||
|
hasVariables,
|
||||||
|
hasMediaHeader,
|
||||||
|
headerComponent,
|
||||||
|
renderedTemplate,
|
||||||
|
v$,
|
||||||
|
updateMediaUrl,
|
||||||
|
sendMessage,
|
||||||
|
resetTemplate,
|
||||||
|
goBack,
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-sm font-medium text-n-slate-12">
|
||||||
|
{{ template.name }}
|
||||||
|
</h3>
|
||||||
|
<span class="text-xs text-n-slate-11">
|
||||||
|
{{ languageLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<div class="rounded-md">
|
||||||
|
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||||
|
{{ renderedTemplate }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-xs text-n-slate-11">
|
||||||
|
{{ categoryLabel }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasVariables || hasMediaHeader">
|
||||||
|
<div v-if="hasMediaHeader" class="mb-4">
|
||||||
|
<p class="mb-2.5 text-sm font-semibold">
|
||||||
|
{{
|
||||||
|
$t('WHATSAPP_TEMPLATES.PARSER.MEDIA_HEADER_LABEL', {
|
||||||
|
type: formatType,
|
||||||
|
}) || `${formatType} Header`
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center mb-2.5">
|
||||||
|
<Input
|
||||||
|
:model-value="processedParams.header?.media_url || ''"
|
||||||
|
type="url"
|
||||||
|
class="flex-1"
|
||||||
|
:placeholder="
|
||||||
|
t('WHATSAPP_TEMPLATES.PARSER.MEDIA_URL_LABEL', {
|
||||||
|
type: formatType,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@update:model-value="updateMediaUrl"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body Variables Section -->
|
||||||
|
<div v-if="processedParams.body">
|
||||||
|
<p class="mb-2.5 text-sm font-semibold">
|
||||||
|
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(variable, key) in processedParams.body"
|
||||||
|
:key="`body-${key}`"
|
||||||
|
class="flex items-center mb-2.5"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="processedParams.body[key]"
|
||||||
|
type="text"
|
||||||
|
class="flex-1"
|
||||||
|
:placeholder="
|
||||||
|
t('WHATSAPP_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
||||||
|
variable: key,
|
||||||
|
})
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Button Variables Section -->
|
||||||
|
<div v-if="processedParams.buttons">
|
||||||
|
<p class="mb-2.5 text-sm font-semibold">
|
||||||
|
{{ t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETERS') }}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
v-for="(button, index) in processedParams.buttons"
|
||||||
|
:key="`button-${index}`"
|
||||||
|
class="flex items-center mb-2.5"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
v-model="processedParams.buttons[index].parameter"
|
||||||
|
type="text"
|
||||||
|
class="flex-1"
|
||||||
|
:placeholder="t('WHATSAPP_TEMPLATES.PARSER.BUTTON_PARAMETER')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
v-if="v$.$dirty && v$.$invalid"
|
||||||
|
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
||||||
|
>
|
||||||
|
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<slot
|
||||||
|
name="actions"
|
||||||
|
:send-message="sendMessage"
|
||||||
|
:reset-template="resetTemplate"
|
||||||
|
:go-back="goBack"
|
||||||
|
:is-valid="!v$.$invalid"
|
||||||
|
:disabled="isFormInvalid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,4 +1,4 @@
|
|||||||
<script>
|
<script setup>
|
||||||
/**
|
/**
|
||||||
* This component handles parsing and sending WhatsApp message templates.
|
* This component handles parsing and sending WhatsApp message templates.
|
||||||
* It works as follows:
|
* It works as follows:
|
||||||
@ -8,158 +8,51 @@
|
|||||||
* 4. Replaces placeholders with user-provided values.
|
* 4. Replaces placeholders with user-provided values.
|
||||||
* 5. Emits events to send the processed message or reset the template.
|
* 5. Emits events to send the processed message or reset the template.
|
||||||
*/
|
*/
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
|
||||||
import { requiredIf } from '@vuelidate/validators';
|
|
||||||
|
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
components: {
|
template: {
|
||||||
NextButton,
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
props: {
|
});
|
||||||
template: {
|
|
||||||
type: Object,
|
|
||||||
default: () => ({}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['sendMessage', 'resetTemplate'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const processVariable = str => {
|
|
||||||
return str.replace(/{{|}}/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const allKeysRequired = value => {
|
const emit = defineEmits(['sendMessage', 'resetTemplate']);
|
||||||
const keys = Object.keys(value);
|
|
||||||
return keys.every(key => value[key]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processedParams = ref({});
|
const handleSendMessage = payload => {
|
||||||
|
emit('sendMessage', payload);
|
||||||
|
};
|
||||||
|
|
||||||
const templateString = computed(() => {
|
const handleResetTemplate = () => {
|
||||||
return props.template.components.find(
|
emit('resetTemplate');
|
||||||
component => component.type === 'BODY'
|
|
||||||
).text;
|
|
||||||
});
|
|
||||||
|
|
||||||
const variables = computed(() => {
|
|
||||||
return templateString.value.match(/{{([^}]+)}}/g);
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedString = computed(() => {
|
|
||||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
|
||||||
const variableKey = processVariable(variable);
|
|
||||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const v$ = useVuelidate(
|
|
||||||
{
|
|
||||||
processedParams: {
|
|
||||||
requiredIfKeysPresent: requiredIf(variables),
|
|
||||||
allKeysRequired,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ processedParams }
|
|
||||||
);
|
|
||||||
|
|
||||||
const generateVariables = () => {
|
|
||||||
const matchedVariables = templateString.value.match(/{{([^}]+)}}/g);
|
|
||||||
if (!matchedVariables) return;
|
|
||||||
|
|
||||||
const finalVars = matchedVariables.map(i => processVariable(i));
|
|
||||||
processedParams.value = finalVars.reduce((acc, variable) => {
|
|
||||||
acc[variable] = '';
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
};
|
|
||||||
|
|
||||||
const resetTemplate = () => {
|
|
||||||
emit('resetTemplate');
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = () => {
|
|
||||||
v$.value.$touch();
|
|
||||||
if (v$.value.$invalid) return;
|
|
||||||
|
|
||||||
const payload = {
|
|
||||||
message: processedString.value,
|
|
||||||
templateParams: {
|
|
||||||
name: props.template.name,
|
|
||||||
category: props.template.category,
|
|
||||||
language: props.template.language,
|
|
||||||
namespace: props.template.namespace,
|
|
||||||
processed_params: processedParams.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
emit('sendMessage', payload);
|
|
||||||
};
|
|
||||||
|
|
||||||
onMounted(generateVariables);
|
|
||||||
|
|
||||||
return {
|
|
||||||
processedParams,
|
|
||||||
variables,
|
|
||||||
templateString,
|
|
||||||
processedString,
|
|
||||||
v$,
|
|
||||||
resetTemplate,
|
|
||||||
sendMessage,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<div class="w-full">
|
||||||
<textarea
|
<WhatsAppTemplateParser
|
||||||
v-model="processedString"
|
:template="template"
|
||||||
rows="4"
|
@send-message="handleSendMessage"
|
||||||
readonly
|
@reset-template="handleResetTemplate"
|
||||||
class="template-input"
|
>
|
||||||
/>
|
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||||
<div v-if="variables" class="p-2.5">
|
<footer class="flex gap-2 justify-end">
|
||||||
<p class="text-sm font-semibold mb-2.5">
|
<NextButton
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
faded
|
||||||
</p>
|
slate
|
||||||
<div
|
type="reset"
|
||||||
v-for="(variable, key) in processedParams"
|
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||||
:key="key"
|
@click="resetTemplate"
|
||||||
class="items-center flex mb-2.5"
|
/>
|
||||||
>
|
<NextButton
|
||||||
<span
|
type="button"
|
||||||
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
|
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||||
>
|
:disabled="disabled"
|
||||||
{{ key }}
|
@click="sendMessage"
|
||||||
</span>
|
/>
|
||||||
<woot-input
|
</footer>
|
||||||
v-model="processedParams[key]"
|
</template>
|
||||||
type="text"
|
</WhatsAppTemplateParser>
|
||||||
class="flex-1 text-sm ml-2.5"
|
|
||||||
:styles="{ marginBottom: 0 }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
v-if="v$.$dirty && v$.$invalid"
|
|
||||||
class="bg-n-ruby-9/20 rounded-md text-n-ruby-9 p-2.5 text-center"
|
|
||||||
>
|
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<footer class="flex justify-end gap-2">
|
|
||||||
<NextButton
|
|
||||||
faded
|
|
||||||
slate
|
|
||||||
type="reset"
|
|
||||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
|
||||||
@click="resetTemplate"
|
|
||||||
/>
|
|
||||||
<NextButton
|
|
||||||
type="button"
|
|
||||||
:label="$t('WHATSAPP_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
|
||||||
@click="sendMessage"
|
|
||||||
/>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@ -1,60 +1,71 @@
|
|||||||
<script>
|
<script setup>
|
||||||
|
import { ref, computed, toRef } from 'vue';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useFunctionGetter, useStore } from 'dashboard/composables/store';
|
||||||
|
import {
|
||||||
|
COMPONENT_TYPES,
|
||||||
|
MEDIA_FORMATS,
|
||||||
|
findComponentByType,
|
||||||
|
} from 'dashboard/helper/templateHelper';
|
||||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||||
// TODO: Remove this when we support all formats
|
import { useI18n } from 'vue-i18n';
|
||||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
|
||||||
|
|
||||||
export default {
|
const props = defineProps({
|
||||||
components: {
|
inboxId: {
|
||||||
Icon,
|
type: Number,
|
||||||
},
|
default: undefined,
|
||||||
props: {
|
|
||||||
inboxId: {
|
|
||||||
type: Number,
|
|
||||||
default: undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
emits: ['onSelect'],
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
query: '',
|
|
||||||
isRefreshing: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
whatsAppTemplateMessages() {
|
|
||||||
// TODO: Remove the last filter when we support all formats
|
|
||||||
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
|
||||||
.filter(template => template.status.toLowerCase() === 'approved')
|
|
||||||
.filter(template => {
|
|
||||||
return template.components.every(component => {
|
|
||||||
return !formatsToRemove.includes(component.format);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
filteredTemplateMessages() {
|
|
||||||
return this.whatsAppTemplateMessages.filter(template =>
|
|
||||||
template.name.toLowerCase().includes(this.query.toLowerCase())
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
getTemplatebody(template) {
|
|
||||||
return template.components.find(component => component.type === 'BODY')
|
|
||||||
.text;
|
|
||||||
},
|
|
||||||
async refreshTemplates() {
|
|
||||||
this.isRefreshing = true;
|
|
||||||
try {
|
|
||||||
await this.$store.dispatch('inboxes/syncTemplates', this.inboxId);
|
|
||||||
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
|
||||||
} catch (error) {
|
|
||||||
useAlert(this.$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
|
||||||
} finally {
|
|
||||||
this.isRefreshing = false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['onSelect']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const query = ref('');
|
||||||
|
const isRefreshing = ref(false);
|
||||||
|
|
||||||
|
const whatsAppTemplateMessages = useFunctionGetter(
|
||||||
|
'inboxes/getFilteredWhatsAppTemplates',
|
||||||
|
toRef(props, 'inboxId')
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredTemplateMessages = computed(() =>
|
||||||
|
whatsAppTemplateMessages.value.filter(template =>
|
||||||
|
template.name.toLowerCase().includes(query.value.toLowerCase())
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const getTemplateBody = template => {
|
||||||
|
return findComponentByType(template, COMPONENT_TYPES.BODY)?.text || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTemplateHeader = template => {
|
||||||
|
return findComponentByType(template, COMPONENT_TYPES.HEADER);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTemplateFooter = template => {
|
||||||
|
return findComponentByType(template, COMPONENT_TYPES.FOOTER);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTemplateButtons = template => {
|
||||||
|
return findComponentByType(template, COMPONENT_TYPES.BUTTONS);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasMediaContent = template => {
|
||||||
|
const header = getTemplateHeader(template);
|
||||||
|
return header && MEDIA_FORMATS.includes(header.format);
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshTemplates = async () => {
|
||||||
|
isRefreshing.value = true;
|
||||||
|
try {
|
||||||
|
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||||
|
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('WHATSAPP_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||||
|
} finally {
|
||||||
|
isRefreshing.value = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -68,14 +79,14 @@ export default {
|
|||||||
<input
|
<input
|
||||||
v-model="query"
|
v-model="query"
|
||||||
type="search"
|
type="search"
|
||||||
:placeholder="$t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
:placeholder="t('WHATSAPP_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
:disabled="isRefreshing"
|
:disabled="isRefreshing"
|
||||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
:title="$t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
:title="t('WHATSAPP_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||||
@click="refreshTemplates"
|
@click="refreshTemplates"
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon
|
||||||
@ -91,7 +102,7 @@ export default {
|
|||||||
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
<div v-for="(template, i) in filteredTemplateMessages" :key="template.id">
|
||||||
<button
|
<button
|
||||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||||
@click="$emit('onSelect', template)"
|
@click="emit('onSelect', template)"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex justify-between items-center mb-2.5">
|
<div class="flex justify-between items-center mb-2.5">
|
||||||
@ -101,21 +112,73 @@ export default {
|
|||||||
<span
|
<span
|
||||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||||
>
|
>
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }} :
|
{{ t('WHATSAPP_TEMPLATES.PICKER.LABELS.LANGUAGE') }}:
|
||||||
{{ template.language }}
|
{{ template.language }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<!-- Header -->
|
||||||
<p class="font-medium">
|
<div v-if="getTemplateHeader(template)" class="mb-3">
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.TEMPLATE_BODY') }}
|
<p class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('WHATSAPP_TEMPLATES.PICKER.HEADER') || 'HEADER' }}
|
||||||
</p>
|
</p>
|
||||||
<p class="label-body">{{ getTemplatebody(template) }}</p>
|
<div
|
||||||
|
v-if="getTemplateHeader(template).format === 'TEXT'"
|
||||||
|
class="text-sm label-body"
|
||||||
|
>
|
||||||
|
{{ getTemplateHeader(template).text }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="hasMediaContent(template)"
|
||||||
|
class="text-sm italic text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
|
||||||
|
format: getTemplateHeader(template).format,
|
||||||
|
}) ||
|
||||||
|
`${getTemplateHeader(template).format} ${t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT_FALLBACK')}`
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-5">
|
|
||||||
<p class="font-medium">
|
<!-- Body -->
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
<div>
|
||||||
|
<p class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('WHATSAPP_TEMPLATES.PICKER.BODY') || 'BODY' }}
|
||||||
</p>
|
</p>
|
||||||
<p>{{ template.category }}</p>
|
<p class="text-sm label-body">{{ getTemplateBody(template) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<div v-if="getTemplateFooter(template)" class="mt-3">
|
||||||
|
<p class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('WHATSAPP_TEMPLATES.PICKER.FOOTER') || 'FOOTER' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm label-body">
|
||||||
|
{{ getTemplateFooter(template).text }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Buttons -->
|
||||||
|
<div v-if="getTemplateButtons(template)" class="mt-3">
|
||||||
|
<p class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('WHATSAPP_TEMPLATES.PICKER.BUTTONS') || 'BUTTONS' }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<span
|
||||||
|
v-for="button in getTemplateButtons(template).buttons"
|
||||||
|
:key="button.text"
|
||||||
|
class="px-2 py-1 text-xs rounded bg-n-slate-3 text-n-slate-12"
|
||||||
|
>
|
||||||
|
{{ button.text }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3">
|
||||||
|
<p class="text-xs font-medium text-n-slate-11">
|
||||||
|
{{ t('WHATSAPP_TEMPLATES.PICKER.CATEGORY') || 'CATEGORY' }}
|
||||||
|
</p>
|
||||||
|
<p class="text-sm">{{ template.category }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@ -128,13 +191,13 @@ export default {
|
|||||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||||
<div v-if="query && whatsAppTemplateMessages.length">
|
<div v-if="query && whatsAppTemplateMessages.length">
|
||||||
<p>
|
<p>
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||||
<strong>{{ query }}</strong>
|
<strong>{{ query }}</strong>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
<div v-else-if="!whatsAppTemplateMessages.length" class="space-y-4">
|
||||||
<p class="text-n-slate-11">
|
<p class="text-n-slate-11">
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
{{ t('WHATSAPP_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
368
app/javascript/dashboard/helper/specs/templateHelper.spec.js
Normal file
368
app/javascript/dashboard/helper/specs/templateHelper.spec.js
Normal file
@ -0,0 +1,368 @@
|
|||||||
|
import {
|
||||||
|
replaceTemplateVariables,
|
||||||
|
buildTemplateParameters,
|
||||||
|
processVariable,
|
||||||
|
allKeysRequired,
|
||||||
|
} from '../templateHelper';
|
||||||
|
import { templates } from '../../store/modules/specs/inboxes/templateFixtures';
|
||||||
|
|
||||||
|
describe('templateHelper', () => {
|
||||||
|
const technicianTemplate = templates.find(t => t.name === 'technician_visit');
|
||||||
|
|
||||||
|
describe('processVariable', () => {
|
||||||
|
it('should remove curly braces from variables', () => {
|
||||||
|
expect(processVariable('{{name}}')).toBe('name');
|
||||||
|
expect(processVariable('{{1}}')).toBe('1');
|
||||||
|
expect(processVariable('{{customer_id}}')).toBe('customer_id');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('allKeysRequired', () => {
|
||||||
|
it('should return true when all keys have values', () => {
|
||||||
|
const obj = { name: 'John', age: '30' };
|
||||||
|
expect(allKeysRequired(obj)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false when some keys are empty', () => {
|
||||||
|
const obj = { name: 'John', age: '' };
|
||||||
|
expect(allKeysRequired(obj)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return true for empty object', () => {
|
||||||
|
expect(allKeysRequired({})).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('replaceTemplateVariables', () => {
|
||||||
|
const templateText =
|
||||||
|
"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.";
|
||||||
|
|
||||||
|
it('should replace all variables with provided values', () => {
|
||||||
|
const processedParams = {
|
||||||
|
body: {
|
||||||
|
1: 'John',
|
||||||
|
2: '123 Main St',
|
||||||
|
3: '2025-01-15',
|
||||||
|
4: '10:00 AM',
|
||||||
|
5: '2:00 PM',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceTemplateVariables(templateText, processedParams);
|
||||||
|
expect(result).toBe(
|
||||||
|
"Hi John, we're scheduling a technician visit to 123 Main St on 2025-01-15 between 10:00 AM and 2:00 PM. Please confirm if this time slot works for you."
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should keep original variable format when no replacement value provided', () => {
|
||||||
|
const processedParams = {
|
||||||
|
body: {
|
||||||
|
1: 'John',
|
||||||
|
3: '2025-01-15',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceTemplateVariables(templateText, processedParams);
|
||||||
|
expect(result).toContain('John');
|
||||||
|
expect(result).toContain('2025-01-15');
|
||||||
|
expect(result).toContain('{{2}}');
|
||||||
|
expect(result).toContain('{{4}}');
|
||||||
|
expect(result).toContain('{{5}}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty processedParams', () => {
|
||||||
|
const result = replaceTemplateVariables(templateText, {});
|
||||||
|
expect(result).toBe(templateText);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildTemplateParameters', () => {
|
||||||
|
it('should build parameters for template with body variables', () => {
|
||||||
|
const result = buildTemplateParameters(technicianTemplate, false);
|
||||||
|
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
1: '',
|
||||||
|
2: '',
|
||||||
|
3: '',
|
||||||
|
4: '',
|
||||||
|
5: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include header parameters when hasMediaHeader is true', () => {
|
||||||
|
const imageTemplate = templates.find(
|
||||||
|
t => t.name === 'order_confirmation'
|
||||||
|
);
|
||||||
|
const result = buildTemplateParameters(imageTemplate, true);
|
||||||
|
|
||||||
|
expect(result.header).toEqual({
|
||||||
|
media_url: '',
|
||||||
|
media_type: 'image',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include header parameters when hasMediaHeader is false', () => {
|
||||||
|
const result = buildTemplateParameters(technicianTemplate, false);
|
||||||
|
expect(result.header).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle template with no body component', () => {
|
||||||
|
const templateWithoutBody = {
|
||||||
|
components: [{ type: 'HEADER', format: 'TEXT' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildTemplateParameters(templateWithoutBody, false);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle template with no variables', () => {
|
||||||
|
const templateWithoutVars = templates.find(
|
||||||
|
t => t.name === 'no_variable_template'
|
||||||
|
);
|
||||||
|
const result = buildTemplateParameters(templateWithoutVars, false);
|
||||||
|
|
||||||
|
expect(result.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle URL buttons with variables for non-authentication templates', () => {
|
||||||
|
const templateWithUrlButton = {
|
||||||
|
category: 'MARKETING',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'BODY',
|
||||||
|
text: 'Check out our website at {{site_url}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
type: 'URL',
|
||||||
|
url: 'https://example.com/{{campaign_id}}',
|
||||||
|
text: 'Visit Site',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildTemplateParameters(templateWithUrlButton, false);
|
||||||
|
expect(result.buttons).toEqual([
|
||||||
|
{
|
||||||
|
type: 'url',
|
||||||
|
parameter: '',
|
||||||
|
url: 'https://example.com/{{campaign_id}}',
|
||||||
|
variables: ['campaign_id'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle templates with no variables', () => {
|
||||||
|
const emptyTemplate = templates.find(
|
||||||
|
t => t.name === 'no_variable_template'
|
||||||
|
);
|
||||||
|
const result = buildTemplateParameters(emptyTemplate, false);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should build parameters for templates with multiple component types', () => {
|
||||||
|
const complexTemplate = {
|
||||||
|
components: [
|
||||||
|
{ type: 'HEADER', format: 'IMAGE' },
|
||||||
|
{ type: 'BODY', text: 'Hi {{1}}, your order {{2}} is ready!' },
|
||||||
|
{ type: 'FOOTER', text: 'Thank you for your business' },
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [{ type: 'URL', url: 'https://example.com/{{3}}' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildTemplateParameters(complexTemplate, true);
|
||||||
|
|
||||||
|
expect(result.header).toEqual({
|
||||||
|
media_url: '',
|
||||||
|
media_type: 'image',
|
||||||
|
});
|
||||||
|
expect(result.body).toEqual({ 1: '', 2: '' });
|
||||||
|
expect(result.buttons).toEqual([
|
||||||
|
{
|
||||||
|
type: 'url',
|
||||||
|
parameter: '',
|
||||||
|
url: 'https://example.com/{{3}}',
|
||||||
|
variables: ['3'],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle copy code buttons correctly', () => {
|
||||||
|
const copyCodeTemplate = templates.find(
|
||||||
|
t => t.name === 'discount_coupon'
|
||||||
|
);
|
||||||
|
const result = buildTemplateParameters(copyCodeTemplate, false);
|
||||||
|
|
||||||
|
expect(result.body).toBeDefined();
|
||||||
|
expect(result.buttons).toEqual([
|
||||||
|
{
|
||||||
|
type: 'copy_code',
|
||||||
|
parameter: '',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle templates with document headers', () => {
|
||||||
|
const documentTemplate = templates.find(
|
||||||
|
t => t.name === 'purchase_receipt'
|
||||||
|
);
|
||||||
|
const result = buildTemplateParameters(documentTemplate, true);
|
||||||
|
|
||||||
|
expect(result.header).toEqual({
|
||||||
|
media_url: '',
|
||||||
|
media_type: 'document',
|
||||||
|
});
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
1: '',
|
||||||
|
2: '',
|
||||||
|
3: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle video header templates', () => {
|
||||||
|
const videoTemplate = templates.find(t => t.name === 'training_video');
|
||||||
|
const result = buildTemplateParameters(videoTemplate, true);
|
||||||
|
|
||||||
|
expect(result.header).toEqual({
|
||||||
|
media_url: '',
|
||||||
|
media_type: 'video',
|
||||||
|
});
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
name: '',
|
||||||
|
date: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('enhanced format validation', () => {
|
||||||
|
it('should validate enhanced format structure', () => {
|
||||||
|
const processedParams = {
|
||||||
|
body: { 1: 'John', 2: 'Order123' },
|
||||||
|
header: {
|
||||||
|
media_url: 'https://example.com/image.jpg',
|
||||||
|
media_type: 'image',
|
||||||
|
},
|
||||||
|
buttons: [{ type: 'copy_code', parameter: 'SAVE20' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
// Test that structure is properly formed
|
||||||
|
expect(processedParams.body).toBeDefined();
|
||||||
|
expect(typeof processedParams.body).toBe('object');
|
||||||
|
expect(processedParams.header).toBeDefined();
|
||||||
|
expect(Array.isArray(processedParams.buttons)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty component sections', () => {
|
||||||
|
const processedParams = {
|
||||||
|
body: {},
|
||||||
|
header: {},
|
||||||
|
buttons: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(allKeysRequired(processedParams.body)).toBe(true);
|
||||||
|
expect(allKeysRequired(processedParams.header)).toBe(true);
|
||||||
|
expect(processedParams.buttons.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate parameter completeness', () => {
|
||||||
|
const incompleteParams = {
|
||||||
|
body: { 1: 'John', 2: '' },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(allKeysRequired(incompleteParams.body)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle edge cases in processVariable', () => {
|
||||||
|
expect(processVariable('{{')).toBe('');
|
||||||
|
expect(processVariable('}}')).toBe('');
|
||||||
|
expect(processVariable('')).toBe('');
|
||||||
|
expect(processVariable('{{nested{{variable}}}}')).toBe('nestedvariable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle special characters in template variables', () => {
|
||||||
|
/* eslint-disable no-template-curly-in-string */
|
||||||
|
const templateText =
|
||||||
|
'Welcome {{user_name}}, your order #{{order_id}} costs ${{amount}}';
|
||||||
|
/* eslint-enable no-template-curly-in-string */
|
||||||
|
const processedParams = {
|
||||||
|
body: {
|
||||||
|
user_name: 'John & Jane',
|
||||||
|
order_id: '12345',
|
||||||
|
amount: '99.99',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceTemplateVariables(templateText, processedParams);
|
||||||
|
expect(result).toBe(
|
||||||
|
'Welcome John & Jane, your order #12345 costs $99.99'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle templates with mixed parameter types', () => {
|
||||||
|
const mixedTemplate = {
|
||||||
|
components: [
|
||||||
|
{ type: 'HEADER', format: 'VIDEO' },
|
||||||
|
{ type: 'BODY', text: 'Order {{order_id}} status: {{status}}' },
|
||||||
|
{ type: 'FOOTER', text: 'Thank you' },
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{ type: 'URL', url: 'https://track.com/{{order_id}}' },
|
||||||
|
{ type: 'COPY_CODE' },
|
||||||
|
{ type: 'PHONE_NUMBER', phone_number: '+1234567890' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildTemplateParameters(mixedTemplate, true);
|
||||||
|
|
||||||
|
expect(result.header).toEqual({
|
||||||
|
media_url: '',
|
||||||
|
media_type: 'video',
|
||||||
|
});
|
||||||
|
expect(result.body).toEqual({
|
||||||
|
order_id: '',
|
||||||
|
status: '',
|
||||||
|
});
|
||||||
|
expect(result.buttons).toHaveLength(2); // URL and COPY_CODE (PHONE_NUMBER doesn't need parameters)
|
||||||
|
expect(result.buttons[0].type).toBe('url');
|
||||||
|
expect(result.buttons[1].type).toBe('copy_code');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle templates with no processable components', () => {
|
||||||
|
const emptyTemplate = {
|
||||||
|
components: [
|
||||||
|
{ type: 'HEADER', format: 'TEXT', text: 'Static Header' },
|
||||||
|
{ type: 'BODY', text: 'Static body with no variables' },
|
||||||
|
{ type: 'FOOTER', text: 'Static footer' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = buildTemplateParameters(emptyTemplate, false);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should validate that replaceTemplateVariables preserves unreplaced variables', () => {
|
||||||
|
const templateText = 'Hi {{name}}, order {{order_id}} is {{status}}';
|
||||||
|
const partialParams = {
|
||||||
|
body: {
|
||||||
|
name: 'John',
|
||||||
|
// order_id missing
|
||||||
|
status: 'ready',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = replaceTemplateVariables(templateText, partialParams);
|
||||||
|
expect(result).toBe('Hi John, order {{order_id}} is ready');
|
||||||
|
expect(result).toContain('{{order_id}}'); // Unreplaced variable preserved
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
91
app/javascript/dashboard/helper/templateHelper.js
Normal file
91
app/javascript/dashboard/helper/templateHelper.js
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// Constants
|
||||||
|
export const DEFAULT_LANGUAGE = 'en';
|
||||||
|
export const DEFAULT_CATEGORY = 'UTILITY';
|
||||||
|
export const COMPONENT_TYPES = {
|
||||||
|
HEADER: 'HEADER',
|
||||||
|
BODY: 'BODY',
|
||||||
|
BUTTONS: 'BUTTONS',
|
||||||
|
};
|
||||||
|
export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT'];
|
||||||
|
|
||||||
|
export const findComponentByType = (template, type) =>
|
||||||
|
template.components?.find(component => component.type === type);
|
||||||
|
|
||||||
|
export const processVariable = str => {
|
||||||
|
return str.replace(/{{|}}/g, '');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const allKeysRequired = value => {
|
||||||
|
const keys = Object.keys(value);
|
||||||
|
return keys.every(key => value[key]);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const replaceTemplateVariables = (templateText, processedParams) => {
|
||||||
|
return templateText.replace(/{{([^}]+)}}/g, (match, variable) => {
|
||||||
|
const variableKey = processVariable(variable);
|
||||||
|
return processedParams.body?.[variableKey] || `{{${variable}}}`;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildTemplateParameters = (template, hasMediaHeaderValue) => {
|
||||||
|
const allVariables = {};
|
||||||
|
|
||||||
|
const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY);
|
||||||
|
const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER);
|
||||||
|
|
||||||
|
if (!bodyComponent) return allVariables;
|
||||||
|
|
||||||
|
const templateString = bodyComponent.text;
|
||||||
|
|
||||||
|
// Process body variables
|
||||||
|
const matchedVariables = templateString.match(/{{([^}]+)}}/g);
|
||||||
|
if (matchedVariables) {
|
||||||
|
allVariables.body = {};
|
||||||
|
matchedVariables.forEach(variable => {
|
||||||
|
const key = processVariable(variable);
|
||||||
|
allVariables.body[key] = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasMediaHeaderValue) {
|
||||||
|
if (!allVariables.header) allVariables.header = {};
|
||||||
|
allVariables.header.media_url = '';
|
||||||
|
allVariables.header.media_type = headerComponent.format.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process button variables
|
||||||
|
const buttonComponents = template.components.filter(
|
||||||
|
component => component.type === COMPONENT_TYPES.BUTTONS
|
||||||
|
);
|
||||||
|
|
||||||
|
buttonComponents.forEach(buttonComponent => {
|
||||||
|
if (buttonComponent.buttons) {
|
||||||
|
buttonComponent.buttons.forEach((button, index) => {
|
||||||
|
// Handle URL buttons with variables
|
||||||
|
if (button.type === 'URL' && button.url && button.url.includes('{{')) {
|
||||||
|
const buttonVars = button.url.match(/{{([^}]+)}}/g) || [];
|
||||||
|
if (buttonVars.length > 0) {
|
||||||
|
if (!allVariables.buttons) allVariables.buttons = [];
|
||||||
|
allVariables.buttons[index] = {
|
||||||
|
type: 'url',
|
||||||
|
parameter: '',
|
||||||
|
url: button.url,
|
||||||
|
variables: buttonVars.map(v => processVariable(v)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle copy code buttons
|
||||||
|
if (button.type === 'COPY_CODE') {
|
||||||
|
if (!allVariables.buttons) allVariables.buttons = [];
|
||||||
|
allVariables.buttons[index] = {
|
||||||
|
type: 'copy_code',
|
||||||
|
parameter: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return allVariables;
|
||||||
|
};
|
||||||
@ -1,29 +1,46 @@
|
|||||||
{
|
{
|
||||||
"WHATSAPP_TEMPLATES": {
|
"WHATSAPP_TEMPLATES": {
|
||||||
"MODAL": {
|
"MODAL": {
|
||||||
"TITLE": "Whatsapp Templates",
|
"TITLE": "Whatsapp Templates",
|
||||||
"SUBTITLE": "Select the whatsapp template you want to send",
|
"SUBTITLE": "Select the whatsapp template you want to send",
|
||||||
"TEMPLATE_SELECTED_SUBTITLE": "Process {templateName}"
|
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||||
},
|
},
|
||||||
"PICKER": {
|
"PICKER": {
|
||||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
"HEADER": "Header",
|
||||||
"REFRESH_BUTTON": "Refresh templates",
|
"BODY": "Body",
|
||||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
"FOOTER": "Footer",
|
||||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
"BUTTONS": "Buttons",
|
||||||
"LABELS": {
|
"CATEGORY": "Category",
|
||||||
"LANGUAGE": "Language",
|
"MEDIA_CONTENT": "Media Content",
|
||||||
"TEMPLATE_BODY": "Template Body",
|
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||||
"CATEGORY": "Category"
|
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||||
}
|
"REFRESH_BUTTON": "Refresh templates",
|
||||||
},
|
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||||
"PARSER": {
|
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||||
"VARIABLES_LABEL": "Variables",
|
"LABELS": {
|
||||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
"LANGUAGE": "Language",
|
||||||
"GO_BACK_LABEL": "Go Back",
|
"TEMPLATE_BODY": "Template Body",
|
||||||
"SEND_MESSAGE_LABEL": "Send Message",
|
"CATEGORY": "Category"
|
||||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
|
}
|
||||||
}
|
},
|
||||||
|
"PARSER": {
|
||||||
|
"VARIABLES_LABEL": "Variables",
|
||||||
|
"LANGUAGE": "Language",
|
||||||
|
"CATEGORY": "Category",
|
||||||
|
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||||
|
"GO_BACK_LABEL": "Go Back",
|
||||||
|
"SEND_MESSAGE_LABEL": "Send Message",
|
||||||
|
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
||||||
|
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||||
|
"OTP_CODE": "Enter 4-8 digit OTP",
|
||||||
|
"EXPIRY_MINUTES": "Enter expiry minutes",
|
||||||
|
"BUTTON_PARAMETERS": "Button Parameters",
|
||||||
|
"BUTTON_LABEL": "Button {index}",
|
||||||
|
"COUPON_CODE": "Enter coupon code (max 15 chars)",
|
||||||
|
"MEDIA_URL_LABEL": "Enter {type} URL",
|
||||||
|
"BUTTON_PARAMETER": "Enter button parameter"
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,15 +44,52 @@ export const getters = {
|
|||||||
const messagesTemplates =
|
const messagesTemplates =
|
||||||
whatsAppMessageTemplates || apiInboxMessageTemplates;
|
whatsAppMessageTemplates || apiInboxMessageTemplates;
|
||||||
|
|
||||||
// filtering out the whatsapp templates with media
|
return messagesTemplates;
|
||||||
if (messagesTemplates instanceof Array) {
|
},
|
||||||
return messagesTemplates.filter(template => {
|
getFilteredWhatsAppTemplates: $state => inboxId => {
|
||||||
return !template.components.some(
|
const [inbox] = $state.records.filter(
|
||||||
i => i.format === 'IMAGE' || i.format === 'VIDEO'
|
record => record.id === Number(inboxId)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
const {
|
||||||
|
message_templates: whatsAppMessageTemplates,
|
||||||
|
additional_attributes: additionalAttributes,
|
||||||
|
} = inbox || {};
|
||||||
|
|
||||||
|
const { message_templates: apiInboxMessageTemplates } =
|
||||||
|
additionalAttributes || {};
|
||||||
|
const templates = whatsAppMessageTemplates || apiInboxMessageTemplates;
|
||||||
|
|
||||||
|
if (!templates || !Array.isArray(templates)) {
|
||||||
|
return [];
|
||||||
}
|
}
|
||||||
return [];
|
|
||||||
|
return templates.filter(template => {
|
||||||
|
// Ensure template has required properties
|
||||||
|
if (!template || !template.status || !template.components) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only show approved templates
|
||||||
|
if (template.status.toLowerCase() !== 'approved') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates
|
||||||
|
const hasUnsupportedComponents = template.components.some(
|
||||||
|
component =>
|
||||||
|
['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes(
|
||||||
|
component.type
|
||||||
|
) ||
|
||||||
|
(component.type === 'HEADER' && component.format === 'LOCATION')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasUnsupportedComponents) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
getNewConversationInboxes($state) {
|
getNewConversationInboxes($state) {
|
||||||
return $state.records.filter(inbox => {
|
return $state.records.filter(inbox => {
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { getters } from '../../inboxes';
|
import { getters } from '../../inboxes';
|
||||||
import inboxList from './fixtures';
|
import inboxList from './fixtures';
|
||||||
|
import { templates } from './templateFixtures';
|
||||||
|
|
||||||
describe('#getters', () => {
|
describe('#getters', () => {
|
||||||
it('getInboxes', () => {
|
it('getInboxes', () => {
|
||||||
@ -93,4 +94,269 @@ describe('#getters', () => {
|
|||||||
provider: 'default',
|
provider: 'default',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getFilteredWhatsAppTemplates', () => {
|
||||||
|
it('returns empty array when inbox not found', () => {
|
||||||
|
const state = { records: [] };
|
||||||
|
expect(getters.getFilteredWhatsAppTemplates(state)(999)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when templates is null or undefined', () => {
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: null,
|
||||||
|
additional_attributes: { message_templates: undefined },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when templates is not an array', () => {
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: 'invalid',
|
||||||
|
additional_attributes: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out templates without required properties', () => {
|
||||||
|
const invalidTemplates = [
|
||||||
|
{ name: 'incomplete_template' }, // missing status and components
|
||||||
|
{ status: 'approved' }, // missing name and components
|
||||||
|
{ name: 'another_incomplete', status: 'approved' }, // missing components
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: invalidTemplates,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(getters.getFilteredWhatsAppTemplates(state)(1)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out non-approved templates', () => {
|
||||||
|
const mixedStatusTemplates = [
|
||||||
|
{
|
||||||
|
name: 'pending_template',
|
||||||
|
status: 'pending',
|
||||||
|
components: [{ type: 'BODY', text: 'Test' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'rejected_template',
|
||||||
|
status: 'rejected',
|
||||||
|
components: [{ type: 'BODY', text: 'Test' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'approved_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [{ type: 'BODY', text: 'Test' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: mixedStatusTemplates,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('approved_template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out interactive templates (LIST, PRODUCT, CATALOG)', () => {
|
||||||
|
const interactiveTemplates = [
|
||||||
|
{
|
||||||
|
name: 'list_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [
|
||||||
|
{ type: 'BODY', text: 'Choose an option' },
|
||||||
|
{ type: 'LIST', sections: [] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'product_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [
|
||||||
|
{ type: 'BODY', text: 'Product info' },
|
||||||
|
{ type: 'PRODUCT', catalog_id: '123' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'catalog_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [
|
||||||
|
{ type: 'BODY', text: 'Catalog' },
|
||||||
|
{ type: 'CATALOG', thumbnail_product_retailer_id: '123' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'regular_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [{ type: 'BODY', text: 'Regular message' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: interactiveTemplates,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('regular_template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out location templates', () => {
|
||||||
|
const locationTemplates = [
|
||||||
|
{
|
||||||
|
name: 'location_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [
|
||||||
|
{ type: 'HEADER', format: 'LOCATION' },
|
||||||
|
{ type: 'BODY', text: 'Location message' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'regular_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [
|
||||||
|
{ type: 'HEADER', format: 'TEXT', text: 'Header' },
|
||||||
|
{ type: 'BODY', text: 'Regular message' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: locationTemplates,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('regular_template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns valid templates from fixture data', () => {
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: templates,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||||
|
|
||||||
|
// All templates in fixtures should be approved and valid
|
||||||
|
expect(result.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Verify all returned templates are approved
|
||||||
|
result.forEach(template => {
|
||||||
|
expect(template.status).toBe('approved');
|
||||||
|
expect(template.components).toBeDefined();
|
||||||
|
expect(Array.isArray(template.components)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify specific templates from fixtures are included
|
||||||
|
const templateNames = result.map(t => t.name);
|
||||||
|
expect(templateNames).toContain('sample_flight_confirmation');
|
||||||
|
expect(templateNames).toContain('sample_issue_resolution');
|
||||||
|
expect(templateNames).toContain('sample_shipping_confirmation');
|
||||||
|
expect(templateNames).toContain('no_variable_template');
|
||||||
|
expect(templateNames).toContain('order_confirmation');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes message_templates over additional_attributes.message_templates', () => {
|
||||||
|
const primaryTemplates = [
|
||||||
|
{
|
||||||
|
name: 'primary_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [{ type: 'BODY', text: 'Primary' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const fallbackTemplates = [
|
||||||
|
{
|
||||||
|
name: 'fallback_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [{ type: 'BODY', text: 'Fallback' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: primaryTemplates,
|
||||||
|
additional_attributes: {
|
||||||
|
message_templates: fallbackTemplates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('primary_template');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to additional_attributes.message_templates when message_templates is null', () => {
|
||||||
|
const fallbackTemplates = [
|
||||||
|
{
|
||||||
|
name: 'fallback_template',
|
||||||
|
status: 'approved',
|
||||||
|
components: [{ type: 'BODY', text: 'Fallback' }],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
channel_type: 'Channel::Whatsapp',
|
||||||
|
message_templates: null,
|
||||||
|
additional_attributes: {
|
||||||
|
message_templates: fallbackTemplates,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = getters.getFilteredWhatsAppTemplates(state)(1);
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].name).toBe('fallback_template');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -260,4 +260,285 @@ export const templates = [
|
|||||||
],
|
],
|
||||||
rejected_reason: 'NONE',
|
rejected_reason: 'NONE',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'order_confirmation',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'TICKET_UPDATE',
|
||||||
|
language: 'en_US',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'IMAGE',
|
||||||
|
example: {
|
||||||
|
header_handle: ['https://example.com/shoes.jpg'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Hi your order {{1}} is confirmed. Please wait for further updates',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'technician_visit',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'UTILITY',
|
||||||
|
language: 'en_US',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: 'Technician visit',
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "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.",
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Confirm',
|
||||||
|
type: 'QUICK_REPLY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Reschedule',
|
||||||
|
type: 'QUICK_REPLY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'event_invitation_static',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'MARKETING',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: "You're invited to {{event_name}} at {{location}}, Join us for an amazing experience!",
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
url: 'https://events.example.com/register',
|
||||||
|
text: 'Visit website',
|
||||||
|
type: 'URL',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'https://maps.app.goo.gl/YoWAzRj1GDuxs6qz8',
|
||||||
|
text: 'Get Directions',
|
||||||
|
type: 'URL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'purchase_receipt',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'UTILITY',
|
||||||
|
language: 'en_US',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'DOCUMENT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Thank you for using your {{1}} card at {{2}}. Your {{3}} is attached as a PDF.',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'discount_coupon',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'MARKETING',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: '🎉 Special offer for you! Get {{discount_percentage}}% off your next purchase. Use the code below at checkout',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Copy offer code',
|
||||||
|
type: 'COPY_CODE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'support_callback',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'UTILITY',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: 'Hello {{name}}, our support team will call you regarding ticket # {{ticket_id}}.',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Call Support',
|
||||||
|
type: 'PHONE_NUMBER',
|
||||||
|
phone_number: '+16506677566',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'training_video',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'MARKETING',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'VIDEO',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Hi {{name}}, here's your training video. Please watch by{{date}}.",
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'product_launch',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'MARKETING',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'IMAGE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'New arrival! Our stunning coat now available in {{color}} color.',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Free shipping on orders over $100. Limited time offer.',
|
||||||
|
type: 'FOOTER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'greet',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'MARKETING',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: 'Hey {{customer_name}} how may I help you?',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'hello_world',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'UTILITY',
|
||||||
|
language: 'en_US',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: 'Hello World',
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Welcome and congratulations!! This message demonstrates your ability to send a WhatsApp message notification from the Cloud API, hosted by Meta. Thank you for taking the time to test with us.',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'WhatsApp Business Platform sample message',
|
||||||
|
type: 'FOOTER',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'feedback_request',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'MARKETING',
|
||||||
|
language: 'en',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: "Hey {{name}}, how was your experience with Puma? We'd love your feedback!",
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'BUTTONS',
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
url: 'https://feedback.example.com/survey',
|
||||||
|
text: 'Leave Feedback',
|
||||||
|
type: 'URL',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'address_update',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'UTILITY',
|
||||||
|
language: 'en_US',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: 'Address update',
|
||||||
|
type: 'HEADER',
|
||||||
|
format: 'TEXT',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: 'Hi {{1}}, your delivery address has been successfully updated to {{2}}. Contact {{3}} for any inquiries.',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'delivery_confirmation',
|
||||||
|
status: 'approved',
|
||||||
|
category: 'UTILITY',
|
||||||
|
language: 'en_US',
|
||||||
|
namespace: 'ed41a221_133a_4558_a1d6_192960e3aee9',
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
text: '{{1}}, your order was successfully delivered on {{2}}.\n\nThank you for your purchase.\n',
|
||||||
|
type: 'BODY',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
rejected_reason: 'NONE',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
@ -1,61 +0,0 @@
|
|||||||
import { shallowMount } from '@vue/test-utils';
|
|
||||||
import TemplateParser from '../../../../dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue';
|
|
||||||
import { templates } from './fixtures';
|
|
||||||
import { nextTick } from 'vue';
|
|
||||||
|
|
||||||
const config = {
|
|
||||||
global: {
|
|
||||||
stubs: {
|
|
||||||
NextButton: { template: '<button />' },
|
|
||||||
WootInput: { template: '<input />' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('#WhatsAppTemplates', () => {
|
|
||||||
it('returns all variables from a template string', async () => {
|
|
||||||
const wrapper = shallowMount(TemplateParser, {
|
|
||||||
...config,
|
|
||||||
props: { template: templates[0] },
|
|
||||||
});
|
|
||||||
await nextTick();
|
|
||||||
expect(wrapper.vm.variables).toEqual(['{{1}}', '{{2}}', '{{3}}']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns no variables from a template string if it does not contain variables', async () => {
|
|
||||||
const wrapper = shallowMount(TemplateParser, {
|
|
||||||
...config,
|
|
||||||
props: { template: templates[12] },
|
|
||||||
});
|
|
||||||
await nextTick();
|
|
||||||
expect(wrapper.vm.variables).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the body of a template', async () => {
|
|
||||||
const wrapper = shallowMount(TemplateParser, {
|
|
||||||
...config,
|
|
||||||
props: { template: templates[1] },
|
|
||||||
});
|
|
||||||
await nextTick();
|
|
||||||
const expectedOutput =
|
|
||||||
templates[1].components.find(i => i.type === 'BODY')?.text || '';
|
|
||||||
expect(wrapper.vm.templateString).toEqual(expectedOutput);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates the templates from variable input', async () => {
|
|
||||||
const wrapper = shallowMount(TemplateParser, {
|
|
||||||
...config,
|
|
||||||
props: { template: templates[0] },
|
|
||||||
});
|
|
||||||
await nextTick();
|
|
||||||
|
|
||||||
// Instead of using `setData`, directly modify the `processedParams` using the component's logic
|
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
wrapper.vm.processedParams = { 1: 'abc', 2: 'xyz', 3: 'qwerty' };
|
|
||||||
await wrapper.vm.$nextTick();
|
|
||||||
|
|
||||||
const expectedOutput =
|
|
||||||
'Esta é a sua confirmação de voo para abc-xyz em qwerty.';
|
|
||||||
expect(wrapper.vm.processedString).toEqual(expectedOutput);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Loading…
Reference in New Issue
Block a user