Compare commits
60
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a37fb7cbf4 | ||
|
|
515356d143 | ||
|
|
585f7d5b54 | ||
|
|
0495316fd1 | ||
|
|
7d8c3ca65e | ||
|
|
fdc9e133cd | ||
|
|
ecf01e9004 | ||
|
|
6c64771fb3 | ||
|
|
17018af4ff | ||
|
|
fa2d80535f | ||
|
|
c7ba57e80c | ||
|
|
1d53362471 | ||
|
|
5acae27990 | ||
|
|
c3f5fbe3db | ||
|
|
79e28ae922 | ||
|
|
77aa9b8f74 | ||
|
|
84f53f5dd9 | ||
|
|
609437ee92 | ||
|
|
35f5371e68 | ||
|
|
e8fd7e71d4 | ||
|
|
c8927f1e81 | ||
|
|
9588253206 | ||
|
|
4a419be73a | ||
|
|
356946a413 | ||
|
|
c7af608510 | ||
|
|
6d86c5739c | ||
|
|
1e2ce070aa | ||
|
|
f49c31f7bc | ||
|
|
a3e0d84b15 | ||
|
|
c2b03d68f3 | ||
|
|
533ae33199 | ||
|
|
54453581ed | ||
|
|
f5f729f23a | ||
|
|
a3120add12 | ||
|
|
b49ab0e95e | ||
|
|
74976f9d07 | ||
|
|
f25050e4cd | ||
|
|
9e5ee2da51 | ||
|
|
e2702fd205 | ||
|
|
a7ea895508 | ||
|
|
a883904986 | ||
|
|
5fad897d8e | ||
|
|
d67b0114d0 | ||
|
|
06c5633a28 | ||
|
|
ec5d30745b | ||
|
|
a8e581d057 | ||
|
|
684cb005c3 | ||
|
|
cbde1d9d83 | ||
|
|
cfda6696b6 | ||
|
|
0b816f2a01 | ||
|
|
a0e2936ab3 | ||
|
|
2357f6ae47 | ||
|
|
3c99197e1e | ||
|
|
f93ed667c0 | ||
|
|
01f85a7882 | ||
|
|
4610cff67e | ||
|
|
44d29fa708 | ||
|
|
9329813ff5 | ||
|
|
16011b06a3 | ||
|
|
ee176480da |
+14
-105
@@ -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">
|
||||||
|
|||||||
+4
-2
@@ -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"
|
||||||
|
|||||||
+1
@@ -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"
|
||||||
|
|||||||
+12
-23
@@ -1,23 +1,24 @@
|
|||||||
<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 Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import WhatsappTemplateParser from './WhatsappTemplateParser.vue';
|
import WhatsappTemplateParser from './WhatsappTemplateParser.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);
|
||||||
@@ -25,19 +26,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(() => {
|
||||||
@@ -87,7 +76,7 @@ const handleSendMessage = template => {
|
|||||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto left-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||||
>
|
>
|
||||||
<div class="relative w-full">
|
<div class="relative w-full">
|
||||||
<span class="absolute i-lucide-search size-3.5 top-2 left-3" />
|
<span class="absolute top-2 left-3 i-lucide-search size-3.5" />
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="search"
|
type="search"
|
||||||
@@ -96,13 +85,13 @@ const handleSendMessage = template => {
|
|||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.SEARCH_PLACEHOLDER'
|
||||||
)
|
)
|
||||||
"
|
"
|
||||||
class="w-full h-8 py-2 pl-10 pr-2 text-sm reset-base outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
class="py-2 pr-2 pl-10 w-full h-8 text-sm rounded-lg border-none outline-none reset-base bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
@@ -111,7 +100,7 @@ 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>
|
||||||
|
|||||||
+19
-132
@@ -1,16 +1,12 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref, onMounted } from 'vue';
|
import WhatsAppTemplateParser from 'dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue';
|
||||||
import { useVuelidate } from '@vuelidate/core';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import { requiredIf } from '@vuelidate/validators';
|
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
import Input from 'dashboard/components-next/input/Input.vue';
|
defineProps({
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
template: {
|
template: {
|
||||||
type: Object,
|
type: Object,
|
||||||
required: true,
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -18,139 +14,27 @@ const emit = defineEmits(['sendMessage', 'back']);
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const processedParams = ref({});
|
const handleSendMessage = payload => {
|
||||||
|
|
||||||
const templateName = computed(() => {
|
|
||||||
return props.template?.name || '';
|
|
||||||
});
|
|
||||||
|
|
||||||
const templateString = computed(() => {
|
|
||||||
return props.template?.components?.find(
|
|
||||||
component => component.type === 'BODY'
|
|
||||||
).text;
|
|
||||||
});
|
|
||||||
|
|
||||||
const processVariable = str => {
|
|
||||||
return str.replace(/{{|}}/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const processedString = computed(() => {
|
|
||||||
return templateString.value.replace(/{{([^}]+)}}/g, (match, variable) => {
|
|
||||||
const variableKey = processVariable(variable);
|
|
||||||
return processedParams.value[variableKey] || `{{${variable}}}`;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedStringWithVariableHighlight = computed(() => {
|
|
||||||
const variables = templateString.value.match(/{{([^}]+)}}/g) || [];
|
|
||||||
|
|
||||||
return variables.reduce((result, variable) => {
|
|
||||||
const variableKey = processVariable(variable);
|
|
||||||
const value = processedParams.value[variableKey] || variable;
|
|
||||||
return result.replace(
|
|
||||||
variable,
|
|
||||||
`<span class="break-all text-n-slate-12">${value}</span>`
|
|
||||||
);
|
|
||||||
}, templateString.value);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rules = computed(() => {
|
|
||||||
const paramRules = {};
|
|
||||||
Object.keys(processedParams.value).forEach(key => {
|
|
||||||
paramRules[key] = { required: requiredIf(true) };
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
processedParams: paramRules,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const v$ = useVuelidate(rules, { processedParams });
|
|
||||||
|
|
||||||
const getFieldErrorType = key => {
|
|
||||||
if (!v$.value.processedParams[key]?.$error) return 'info';
|
|
||||||
return 'error';
|
|
||||||
};
|
|
||||||
|
|
||||||
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 sendMessage = async () => {
|
|
||||||
const isValid = await v$.value.$validate();
|
|
||||||
if (!isValid) 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);
|
emit('sendMessage', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
const handleBack = () => {
|
||||||
generateVariables();
|
emit('back');
|
||||||
});
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-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"
|
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto left-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"
|
||||||
>
|
>
|
||||||
<span class="text-sm text-n-slate-12">
|
<div class="w-full">
|
||||||
{{
|
<WhatsAppTemplateParser
|
||||||
t(
|
:template="template"
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.TEMPLATE_NAME',
|
@send-message="handleSendMessage"
|
||||||
{ templateName: templateName }
|
@back="handleBack"
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<p
|
|
||||||
class="mb-0 text-sm text-n-slate-11"
|
|
||||||
v-html="processedStringWithVariableHighlight"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span
|
|
||||||
v-if="Object.keys(processedParams).length"
|
|
||||||
class="text-sm font-medium text-n-slate-12"
|
|
||||||
>
|
>
|
||||||
{{
|
<template #actions="{ sendMessage, goBack }">
|
||||||
t(
|
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||||
'COMPOSE_NEW_CONVERSATION.FORM.WHATSAPP_OPTIONS.TEMPLATE_PARSER.VARIABLES'
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-for="(variable, key) in processedParams"
|
|
||||||
:key="key"
|
|
||||||
class="flex items-center w-full gap-2"
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
class="flex items-center h-8 text-sm min-w-6 ltr:text-left rtl:text-right text-n-slate-10"
|
|
||||||
>
|
|
||||||
{{ key }}
|
|
||||||
</span>
|
|
||||||
<Input
|
|
||||||
v-model="processedParams[key]"
|
|
||||||
custom-input-class="!h-8 w-full !bg-transparent"
|
|
||||||
class="w-full"
|
|
||||||
:message-type="getFieldErrorType(key)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-end justify-between w-full gap-3 h-14">
|
|
||||||
<Button
|
<Button
|
||||||
:label="
|
:label="
|
||||||
t(
|
t(
|
||||||
@@ -160,7 +44,7 @@ onMounted(() => {
|
|||||||
color="slate"
|
color="slate"
|
||||||
variant="faded"
|
variant="faded"
|
||||||
class="w-full font-medium"
|
class="w-full font-medium"
|
||||||
@click="emit('back')"
|
@click="goBack"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
:label="
|
:label="
|
||||||
@@ -172,5 +56,8 @@ onMounted(() => {
|
|||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
</WhatsAppTemplateParser>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } 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,
|
||||||
|
} from 'dashboard/helper/templateHelper';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
template: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const processedParams = ref({});
|
||||||
|
|
||||||
|
const languageLabel = computed(() => {
|
||||||
|
return `${t('WHATSAPP_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const categoryLabel = computed(() => {
|
||||||
|
return `${t('WHATSAPP_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'UTILITY'}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const headerComponent = computed(() => {
|
||||||
|
return props.template.components.find(
|
||||||
|
component => component.type === 'HEADER'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyComponent = computed(() => {
|
||||||
|
return props.template.components.find(component => component.type === 'BODY');
|
||||||
|
});
|
||||||
|
|
||||||
|
const bodyText = computed(() => {
|
||||||
|
return bodyComponent.value.text;
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasMediaHeader = computed(() => {
|
||||||
|
return (
|
||||||
|
headerComponent.value &&
|
||||||
|
headerComponent.value.format &&
|
||||||
|
['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerComponent.value.format)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasVariables = computed(() => {
|
||||||
|
return bodyText.value.match(/{{([^}]+)}}/g);
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderedTemplate = computed(() => {
|
||||||
|
return replaceTemplateVariables(bodyText.value, processedParams.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
{
|
||||||
|
processedParams: {
|
||||||
|
requiredIfKeysPresent: requiredIf(hasVariables),
|
||||||
|
allKeysRequired,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ processedParams }
|
||||||
|
);
|
||||||
|
|
||||||
|
const initializeTemplateParameters = () => {
|
||||||
|
const templateParameters = buildTemplateParameters(
|
||||||
|
props.template,
|
||||||
|
hasMediaHeader.value
|
||||||
|
);
|
||||||
|
processedParams.value = templateParameters;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateMediaUrl = value => {
|
||||||
|
if (!processedParams.value.header) {
|
||||||
|
processedParams.value.header = {};
|
||||||
|
}
|
||||||
|
processedParams.value.header.media_url = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendMessage = () => {
|
||||||
|
v$.value.$touch();
|
||||||
|
if (v$.value.$invalid) return;
|
||||||
|
|
||||||
|
const finalParams = processedParams.value;
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
message: renderedTemplate.value,
|
||||||
|
templateParams: {
|
||||||
|
name: props.template.name,
|
||||||
|
category: props.template.category,
|
||||||
|
language: props.template.language,
|
||||||
|
namespace: props.template.namespace,
|
||||||
|
processed_params: finalParams,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
emit('sendMessage', payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetTemplate = () => {
|
||||||
|
emit('resetTemplate');
|
||||||
|
};
|
||||||
|
|
||||||
|
const goBack = () => {
|
||||||
|
emit('back');
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(initializeTemplateParameters);
|
||||||
|
|
||||||
|
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 bg-n-alpha-black3">
|
||||||
|
<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:
|
||||||
|
headerComponent.format.charAt(0) +
|
||||||
|
headerComponent.format.slice(1).toLowerCase(),
|
||||||
|
}) ||
|
||||||
|
`${
|
||||||
|
headerComponent.format.charAt(0) +
|
||||||
|
headerComponent.format.slice(1).toLowerCase()
|
||||||
|
} 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:
|
||||||
|
headerComponent.format.charAt(0) +
|
||||||
|
headerComponent.format.slice(1).toLowerCase(),
|
||||||
|
})
|
||||||
|
"
|
||||||
|
@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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
+15
-123
@@ -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,146 +8,36 @@
|
|||||||
* 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: {
|
|
||||||
NextButton,
|
|
||||||
},
|
|
||||||
props: {
|
|
||||||
template: {
|
template: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
},
|
|
||||||
emits: ['sendMessage', 'resetTemplate'],
|
|
||||||
setup(props, { emit }) {
|
|
||||||
const processVariable = str => {
|
|
||||||
return str.replace(/{{|}}/g, '');
|
|
||||||
};
|
|
||||||
|
|
||||||
const allKeysRequired = value => {
|
|
||||||
const keys = Object.keys(value);
|
|
||||||
return keys.every(key => value[key]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const processedParams = ref({});
|
|
||||||
|
|
||||||
const templateString = computed(() => {
|
|
||||||
return props.template.components.find(
|
|
||||||
component => component.type === 'BODY'
|
|
||||||
).text;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const variables = computed(() => {
|
const emit = defineEmits(['sendMessage', 'resetTemplate']);
|
||||||
return templateString.value.match(/{{([^}]+)}}/g);
|
|
||||||
});
|
|
||||||
|
|
||||||
const processedString = computed(() => {
|
const handleSendMessage = payload => {
|
||||||
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);
|
emit('sendMessage', payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(generateVariables);
|
const handleResetTemplate = () => {
|
||||||
|
emit('resetTemplate');
|
||||||
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"
|
|
||||||
/>
|
|
||||||
<div v-if="variables" class="p-2.5">
|
|
||||||
<p class="text-sm font-semibold mb-2.5">
|
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
|
||||||
</p>
|
|
||||||
<div
|
|
||||||
v-for="(variable, key) in processedParams"
|
|
||||||
:key="key"
|
|
||||||
class="items-center flex mb-2.5"
|
|
||||||
>
|
>
|
||||||
<span
|
<template #actions="{ sendMessage, resetTemplate }">
|
||||||
class="bg-n-alpha-black2 text-n-slate-12 inline-block rounded-md text-xs py-2.5 px-6"
|
<footer class="flex gap-2 justify-end">
|
||||||
>
|
|
||||||
{{ key }}
|
|
||||||
</span>
|
|
||||||
<woot-input
|
|
||||||
v-model="processedParams[key]"
|
|
||||||
type="text"
|
|
||||||
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
|
<NextButton
|
||||||
faded
|
faded
|
||||||
slate
|
slate
|
||||||
@@ -161,5 +51,7 @@ export default {
|
|||||||
@click="sendMessage"
|
@click="sendMessage"
|
||||||
/>
|
/>
|
||||||
</footer>
|
</footer>
|
||||||
|
</template>
|
||||||
|
</WhatsAppTemplateParser>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+87
-21
@@ -1,8 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
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
|
|
||||||
const formatsToRemove = ['DOCUMENT', 'IMAGE', 'VIDEO'];
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -23,14 +21,9 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
whatsAppTemplateMessages() {
|
whatsAppTemplateMessages() {
|
||||||
// TODO: Remove the last filter when we support all formats
|
return this.$store.getters['inboxes/getFilteredWhatsAppTemplates'](
|
||||||
return this.$store.getters['inboxes/getWhatsAppTemplates'](this.inboxId)
|
this.inboxId
|
||||||
.filter(template => template.status.toLowerCase() === 'approved')
|
);
|
||||||
.filter(template => {
|
|
||||||
return template.components.every(component => {
|
|
||||||
return !formatsToRemove.includes(component.format);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
filteredTemplateMessages() {
|
filteredTemplateMessages() {
|
||||||
return this.whatsAppTemplateMessages.filter(template =>
|
return this.whatsAppTemplateMessages.filter(template =>
|
||||||
@@ -39,9 +32,31 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
getTemplatebody(template) {
|
getTemplateBody(template) {
|
||||||
return template.components.find(component => component.type === 'BODY')
|
return (
|
||||||
.text;
|
template.components.find(component => component.type === 'BODY')
|
||||||
|
?.text || ''
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getTemplateHeader(template) {
|
||||||
|
return template.components.find(component => component.type === 'HEADER');
|
||||||
|
},
|
||||||
|
getTemplateFooter(template) {
|
||||||
|
return template.components.find(component => component.type === 'FOOTER');
|
||||||
|
},
|
||||||
|
getTemplateButtons(template) {
|
||||||
|
return template.components.find(
|
||||||
|
component => component.type === 'BUTTONS'
|
||||||
|
);
|
||||||
|
},
|
||||||
|
hasMediaContent(template) {
|
||||||
|
const header = this.getTemplateHeader(template);
|
||||||
|
return (
|
||||||
|
header &&
|
||||||
|
(header.format === 'IMAGE' ||
|
||||||
|
header.format === 'VIDEO' ||
|
||||||
|
header.format === 'DOCUMENT')
|
||||||
|
);
|
||||||
},
|
},
|
||||||
async refreshTemplates() {
|
async refreshTemplates() {
|
||||||
this.isRefreshing = true;
|
this.isRefreshing = true;
|
||||||
@@ -105,17 +120,68 @@ export default {
|
|||||||
{{ 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>
|
||||||
<div class="mt-5">
|
<div
|
||||||
<p class="font-medium">
|
v-else-if="hasMediaContent(template)"
|
||||||
{{ $t('WHATSAPP_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
class="text-sm italic text-n-slate-11"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
$t('WHATSAPP_TEMPLATES.PICKER.MEDIA_CONTENT', {
|
||||||
|
format: getTemplateHeader(template).format,
|
||||||
|
}) || `${getTemplateHeader(template).format} media content`
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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 = template.components.find(
|
||||||
|
component => component.type === 'BODY'
|
||||||
|
);
|
||||||
|
const headerComponent = template.components.find(
|
||||||
|
component => component.type === '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 === '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;
|
||||||
|
};
|
||||||
@@ -3,11 +3,17 @@
|
|||||||
"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",
|
||||||
|
"HEADER": "Header",
|
||||||
|
"BODY": "Body",
|
||||||
|
"FOOTER": "Footer",
|
||||||
|
"BUTTONS": "Buttons",
|
||||||
|
"CATEGORY": "Category",
|
||||||
|
"MEDIA_CONTENT": "Media Content",
|
||||||
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
"NO_TEMPLATES_AVAILABLE": "No WhatsApp templates available. Click refresh to sync templates from WhatsApp.",
|
||||||
"REFRESH_BUTTON": "Refresh templates",
|
"REFRESH_BUTTON": "Refresh templates",
|
||||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||||
@@ -20,10 +26,20 @@
|
|||||||
},
|
},
|
||||||
"PARSER": {
|
"PARSER": {
|
||||||
"VARIABLES_LABEL": "Variables",
|
"VARIABLES_LABEL": "Variables",
|
||||||
|
"LANGUAGE": "Language",
|
||||||
|
"CATEGORY": "Category",
|
||||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||||
"GO_BACK_LABEL": "Go Back",
|
"GO_BACK_LABEL": "Go Back",
|
||||||
"SEND_MESSAGE_LABEL": "Send Message",
|
"SEND_MESSAGE_LABEL": "Send Message",
|
||||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending"
|
"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,50 @@ 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) and location templates
|
||||||
|
const hasUnsupportedComponents = template.components.some(
|
||||||
|
component =>
|
||||||
|
['LIST', 'PRODUCT', 'CATALOG'].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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+281
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -106,10 +106,7 @@ class Whatsapp::Providers::Whatsapp360DialogService < Whatsapp::Providers::BaseS
|
|||||||
policy: 'deterministic',
|
policy: 'deterministic',
|
||||||
code: template_info[:lang_code]
|
code: template_info[:lang_code]
|
||||||
},
|
},
|
||||||
components: [{
|
components: template_info[:parameters]
|
||||||
type: 'body',
|
|
||||||
parameters: template_info[:parameters]
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -12,15 +12,20 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def send_template(phone_number, template_info)
|
def send_template(phone_number, template_info)
|
||||||
|
template_body = template_body_parameters(template_info)
|
||||||
|
|
||||||
|
request_body = {
|
||||||
|
messaging_product: 'whatsapp',
|
||||||
|
recipient_type: 'individual', # Only individual messages supported (not group messages)
|
||||||
|
to: phone_number,
|
||||||
|
type: 'template',
|
||||||
|
template: template_body
|
||||||
|
}
|
||||||
|
|
||||||
response = HTTParty.post(
|
response = HTTParty.post(
|
||||||
"#{phone_id_path}/messages",
|
"#{phone_id_path}/messages",
|
||||||
headers: api_headers,
|
headers: api_headers,
|
||||||
body: {
|
body: request_body.to_json
|
||||||
messaging_product: 'whatsapp',
|
|
||||||
to: phone_number,
|
|
||||||
template: template_body_parameters(template_info),
|
|
||||||
type: 'template'
|
|
||||||
}.to_json
|
|
||||||
)
|
)
|
||||||
|
|
||||||
process_response(response)
|
process_response(response)
|
||||||
@@ -119,17 +124,36 @@ class Whatsapp::Providers::WhatsappCloudService < Whatsapp::Providers::BaseServi
|
|||||||
end
|
end
|
||||||
|
|
||||||
def template_body_parameters(template_info)
|
def template_body_parameters(template_info)
|
||||||
{
|
template_body = {
|
||||||
name: template_info[:name],
|
name: template_info[:name],
|
||||||
language: {
|
language: {
|
||||||
policy: 'deterministic',
|
policy: 'deterministic',
|
||||||
code: template_info[:lang_code]
|
code: template_info[:lang_code]
|
||||||
},
|
|
||||||
components: [{
|
|
||||||
type: 'body',
|
|
||||||
parameters: template_info[:parameters]
|
|
||||||
}]
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Enhanced template parameters structure
|
||||||
|
# Note: Legacy format support (simple parameter arrays) has been removed
|
||||||
|
# in favor of the enhanced component-based structure that supports
|
||||||
|
# headers, buttons, and authentication templates.
|
||||||
|
#
|
||||||
|
# Expected payload format from frontend:
|
||||||
|
# {
|
||||||
|
# processed_params: {
|
||||||
|
# body: { '1': 'John', '2': '123 Main St' },
|
||||||
|
# header: { media_url: 'https://...', media_type: 'image' },
|
||||||
|
# buttons: [{ type: 'url', parameter: 'otp123456' }]
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# This gets transformed into WhatsApp API component format:
|
||||||
|
# [
|
||||||
|
# { type: 'body', parameters: [...] },
|
||||||
|
# { type: 'header', parameters: [...] },
|
||||||
|
# { type: 'button', sub_type: 'url', parameters: [...] }
|
||||||
|
# ]
|
||||||
|
template_body[:components] = template_info[:parameters] || []
|
||||||
|
|
||||||
|
template_body
|
||||||
end
|
end
|
||||||
|
|
||||||
def whatsapp_reply_context(message)
|
def whatsapp_reply_context(message)
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Service to convert legacy WhatsApp template parameter formats to enhanced format
|
||||||
|
#
|
||||||
|
# Legacy formats (deprecated):
|
||||||
|
# - Array: ["John", "Order123"] - positional parameters
|
||||||
|
# - Flat Hash: {"1": "John", "2": "Order123"} - direct key-value mapping
|
||||||
|
#
|
||||||
|
# Enhanced format:
|
||||||
|
# - Component-based: {"body": {"1": "John", "2": "Order123"}} - structured by template components
|
||||||
|
# - Supports header, body, footer, and button parameters separately
|
||||||
|
#
|
||||||
|
class Whatsapp::TemplateConverterService
|
||||||
|
def initialize(template_params, template)
|
||||||
|
@template_params = template_params
|
||||||
|
@template = template
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_to_enhanced
|
||||||
|
processed_params = @template_params['processed_params']
|
||||||
|
|
||||||
|
# Early return if already enhanced format
|
||||||
|
return @template_params if enhanced_format?(processed_params)
|
||||||
|
|
||||||
|
# Convert legacy formats to enhanced structure
|
||||||
|
# TODO: Legacy format support will be deprecated and removed after 2-3 releases
|
||||||
|
enhanced_params = convert_legacy_to_enhanced(processed_params, @template)
|
||||||
|
|
||||||
|
# Replace original params with enhanced structure
|
||||||
|
@template_params['processed_params'] = enhanced_params
|
||||||
|
@template_params['format_version'] = 'enhanced'
|
||||||
|
|
||||||
|
@template_params
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def enhanced_format?(processed_params)
|
||||||
|
return false unless processed_params.is_a?(Hash)
|
||||||
|
|
||||||
|
# Enhanced format has component-based structure
|
||||||
|
component_keys = %w[body header footer buttons]
|
||||||
|
has_component_structure = processed_params.keys.any? { |k| component_keys.include?(k) }
|
||||||
|
|
||||||
|
# Additional validation for enhanced format
|
||||||
|
if has_component_structure
|
||||||
|
validate_enhanced_structure(processed_params)
|
||||||
|
else
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_enhanced_structure(params)
|
||||||
|
valid_body?(params['body']) &&
|
||||||
|
valid_header?(params['header']) &&
|
||||||
|
valid_buttons?(params['buttons'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_body?(body)
|
||||||
|
body.nil? || body.is_a?(Hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_header?(header)
|
||||||
|
header.nil? || header.is_a?(Hash)
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_buttons?(buttons)
|
||||||
|
return true if buttons.nil?
|
||||||
|
return false unless buttons.is_a?(Array)
|
||||||
|
|
||||||
|
buttons.all? { |b| b.is_a?(Hash) && b['type'] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert_legacy_to_enhanced(legacy_params, _template)
|
||||||
|
# Legacy system only supported text-based templates with body parameters
|
||||||
|
# We only convert the parameter format, not add new features
|
||||||
|
|
||||||
|
enhanced = {}
|
||||||
|
|
||||||
|
case legacy_params
|
||||||
|
when Array
|
||||||
|
# Array format: ["John", "Order123"] → {body: {"1": "John", "2": "Order123"}}
|
||||||
|
body_params = convert_array_to_body_params(legacy_params)
|
||||||
|
enhanced['body'] = body_params unless body_params.empty?
|
||||||
|
when Hash
|
||||||
|
# Hash format: {"1": "John", "name": "Jane"} → {body: {"1": "John", "name": "Jane"}}
|
||||||
|
body_params = convert_hash_to_body_params(legacy_params)
|
||||||
|
enhanced['body'] = body_params unless body_params.empty?
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unknown legacy format: #{legacy_params.class}"
|
||||||
|
end
|
||||||
|
|
||||||
|
enhanced
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert_array_to_body_params(params_array)
|
||||||
|
return {} if params_array.empty?
|
||||||
|
|
||||||
|
body_params = {}
|
||||||
|
params_array.each_with_index do |value, index|
|
||||||
|
body_params[(index + 1).to_s] = value.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
body_params
|
||||||
|
end
|
||||||
|
|
||||||
|
def convert_hash_to_body_params(params_hash)
|
||||||
|
return {} if params_hash.empty?
|
||||||
|
|
||||||
|
body_params = {}
|
||||||
|
params_hash.each do |key, value|
|
||||||
|
body_params[key.to_s] = value.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
body_params
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
class Whatsapp::TemplateParameterService
|
||||||
|
def build_parameter(value)
|
||||||
|
case value
|
||||||
|
when String
|
||||||
|
build_string_parameter(value)
|
||||||
|
when Hash
|
||||||
|
build_hash_parameter(value)
|
||||||
|
else
|
||||||
|
{ type: 'text', text: value.to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_button_parameter(button)
|
||||||
|
return { type: 'text', text: '' } if button.blank?
|
||||||
|
|
||||||
|
case button['type']
|
||||||
|
when 'copy_code'
|
||||||
|
coupon_code = button['parameter'].to_s.strip
|
||||||
|
raise ArgumentError, 'Coupon code cannot be empty' if coupon_code.blank?
|
||||||
|
raise ArgumentError, 'Coupon code cannot exceed 15 characters' if coupon_code.length > 15
|
||||||
|
|
||||||
|
{
|
||||||
|
type: 'coupon_code',
|
||||||
|
coupon_code: coupon_code
|
||||||
|
}
|
||||||
|
else
|
||||||
|
# For URL buttons and other button types, treat parameter as text
|
||||||
|
# If parameter is blank, use empty string (required for URL buttons)
|
||||||
|
{ type: 'text', text: button['parameter'].to_s.strip }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_media_parameter(url, media_type)
|
||||||
|
return nil if url.blank?
|
||||||
|
|
||||||
|
sanitized_url = sanitize_parameter(url)
|
||||||
|
validate_url(sanitized_url)
|
||||||
|
build_media_type_parameter(sanitized_url, media_type.downcase)
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_named_parameter(parameter_name, value)
|
||||||
|
sanitized_value = sanitize_parameter(value.to_s)
|
||||||
|
{ type: 'text', parameter_name: parameter_name, text: sanitized_value }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_string_parameter(value)
|
||||||
|
sanitized_value = sanitize_parameter(value)
|
||||||
|
if rich_formatting?(sanitized_value)
|
||||||
|
build_rich_text_parameter(sanitized_value)
|
||||||
|
else
|
||||||
|
{ type: 'text', text: sanitized_value }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_hash_parameter(value)
|
||||||
|
case value['type']
|
||||||
|
when 'currency'
|
||||||
|
build_currency_parameter(value)
|
||||||
|
when 'date_time'
|
||||||
|
build_date_time_parameter(value)
|
||||||
|
else
|
||||||
|
{ type: 'text', text: value.to_s }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_currency_parameter(value)
|
||||||
|
{
|
||||||
|
type: 'currency',
|
||||||
|
currency: {
|
||||||
|
fallback_value: value['fallback_value'],
|
||||||
|
code: value['code'],
|
||||||
|
amount_1000: value['amount_1000']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_date_time_parameter(value)
|
||||||
|
{
|
||||||
|
type: 'date_time',
|
||||||
|
date_time: {
|
||||||
|
fallback_value: value['fallback_value'],
|
||||||
|
day_of_week: value['day_of_week'],
|
||||||
|
day_of_month: value['day_of_month'],
|
||||||
|
month: value['month'],
|
||||||
|
year: value['year']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_media_type_parameter(sanitized_url, media_type)
|
||||||
|
case media_type
|
||||||
|
when 'image'
|
||||||
|
build_image_parameter(sanitized_url)
|
||||||
|
when 'video'
|
||||||
|
build_video_parameter(sanitized_url)
|
||||||
|
when 'document'
|
||||||
|
build_document_parameter(sanitized_url)
|
||||||
|
else
|
||||||
|
raise ArgumentError, "Unsupported media type: #{media_type}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_image_parameter(url)
|
||||||
|
{ type: 'image', image: { link: url } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_video_parameter(url)
|
||||||
|
{ type: 'video', video: { link: url } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_document_parameter(url)
|
||||||
|
{ type: 'document', document: { link: url } }
|
||||||
|
end
|
||||||
|
|
||||||
|
def rich_formatting?(text)
|
||||||
|
# Check if text contains WhatsApp rich formatting markers
|
||||||
|
text.match?(/\*[^*]+\*/) || # Bold: *text*
|
||||||
|
text.match?(/_[^_]+_/) || # Italic: _text_
|
||||||
|
text.match?(/~[^~]+~/) || # Strikethrough: ~text~
|
||||||
|
text.match?(/```[^`]+```/) # Monospace: ```text```
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_rich_text_parameter(text)
|
||||||
|
# WhatsApp supports rich text formatting in templates
|
||||||
|
# This preserves the formatting markers for the API
|
||||||
|
{ type: 'text', text: text }
|
||||||
|
end
|
||||||
|
|
||||||
|
def sanitize_parameter(value)
|
||||||
|
# Basic sanitization - remove dangerous characters and limit length
|
||||||
|
sanitized = value.to_s.strip
|
||||||
|
sanitized = sanitized.gsub(/[<>\"']/, '') # Remove potential HTML/JS chars
|
||||||
|
sanitized[0...1000] # Limit length to prevent DoS
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_url(url)
|
||||||
|
return if url.blank?
|
||||||
|
|
||||||
|
uri = URI.parse(url)
|
||||||
|
raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme)
|
||||||
|
raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000
|
||||||
|
|
||||||
|
rescue URI::InvalidURIError => e
|
||||||
|
raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid URL like https://example.com/document.pdf"
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,11 +2,9 @@ class Whatsapp::TemplateProcessorService
|
|||||||
pattr_initialize [:channel!, :template_params, :message]
|
pattr_initialize [:channel!, :template_params, :message]
|
||||||
|
|
||||||
def call
|
def call
|
||||||
if template_params.present?
|
return [nil, nil, nil, nil] if template_params.blank?
|
||||||
|
|
||||||
process_template_with_params
|
process_template_with_params
|
||||||
else
|
|
||||||
process_template_from_message
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -20,51 +18,6 @@ class Whatsapp::TemplateProcessorService
|
|||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
def process_template_from_message
|
|
||||||
return [nil, nil, nil, nil] if message.blank?
|
|
||||||
|
|
||||||
# Delete the following logic once the update for template_params is stable
|
|
||||||
# see if we can match the message content to a template
|
|
||||||
# An example template may look like "Your package has been shipped. It will be delivered in {{1}} business days.
|
|
||||||
# We want to iterate over these templates with our message body and see if we can fit it to any of the templates
|
|
||||||
# Then we use regex to parse the template varibles and convert them into the proper payload
|
|
||||||
channel.message_templates&.each do |template|
|
|
||||||
match_obj = template_match_object(template)
|
|
||||||
next if match_obj.blank?
|
|
||||||
|
|
||||||
# we have a match, now we need to parse the template variables and convert them into the wa recommended format
|
|
||||||
processed_parameters = match_obj.captures.map { |x| { type: 'text', text: x } }
|
|
||||||
|
|
||||||
# no need to look up further end the search
|
|
||||||
return [template['name'], template['namespace'], template['language'], processed_parameters]
|
|
||||||
end
|
|
||||||
[nil, nil, nil, nil]
|
|
||||||
end
|
|
||||||
|
|
||||||
def template_match_object(template)
|
|
||||||
body_object = validated_body_object(template)
|
|
||||||
return if body_object.blank?
|
|
||||||
|
|
||||||
template_match_regex = build_template_match_regex(body_object['text'])
|
|
||||||
message.outgoing_content.match(template_match_regex)
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_template_match_regex(template_text)
|
|
||||||
# Converts the whatsapp template to a comparable regex string to check against the message content
|
|
||||||
# the variables are of the format {{num}} ex:{{1}}
|
|
||||||
|
|
||||||
# transform the template text into a regex string
|
|
||||||
# we need to replace the {{num}} with matchers that can be used to capture the variables
|
|
||||||
template_text = template_text.gsub(/{{\d}}/, '(.*)')
|
|
||||||
# escape if there are regex characters in the template text
|
|
||||||
template_text = Regexp.escape(template_text)
|
|
||||||
# ensuring only the variables remain as capture groups
|
|
||||||
template_text = template_text.gsub(Regexp.escape('(.*)'), '(.*)')
|
|
||||||
|
|
||||||
template_match_string = "^#{template_text}$"
|
|
||||||
Regexp.new template_match_string
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_template
|
def find_template
|
||||||
channel.message_templates.find do |t|
|
channel.message_templates.find do |t|
|
||||||
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
|
t['name'] == template_params['name'] && t['language'] == template_params['language'] && t['status']&.downcase == 'approved'
|
||||||
@@ -75,21 +28,100 @@ class Whatsapp::TemplateProcessorService
|
|||||||
template = find_template
|
template = find_template
|
||||||
return if template.blank?
|
return if template.blank?
|
||||||
|
|
||||||
|
# Convert legacy format to enhanced format before processing
|
||||||
|
converter = Whatsapp::TemplateConverterService.new(template_params, template)
|
||||||
|
normalized_params = converter.normalize_to_enhanced
|
||||||
|
|
||||||
|
process_enhanced_template_params(template, normalized_params['processed_params'])
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_enhanced_template_params(template, processed_params = nil)
|
||||||
|
processed_params ||= template_params['processed_params']
|
||||||
|
components = []
|
||||||
|
|
||||||
|
components.concat(process_header_components(processed_params))
|
||||||
|
components.concat(process_body_components(processed_params, template))
|
||||||
|
components.concat(process_footer_components(processed_params))
|
||||||
|
components.concat(process_button_components(processed_params))
|
||||||
|
|
||||||
|
@template_params = components
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_header_components(processed_params)
|
||||||
|
return [] if processed_params['header'].blank?
|
||||||
|
|
||||||
|
header_params = build_header_params(processed_params['header'])
|
||||||
|
header_params.present? ? [{ type: 'header', parameters: header_params }] : []
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_header_params(header_data)
|
||||||
|
header_params = []
|
||||||
|
header_data.each do |key, value|
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
|
if media_url_with_type?(key, header_data)
|
||||||
|
media_param = parameter_builder.build_media_parameter(value, header_data['media_type'])
|
||||||
|
header_params << media_param if media_param
|
||||||
|
elsif key != 'media_type'
|
||||||
|
header_params << parameter_builder.build_parameter(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
header_params
|
||||||
|
end
|
||||||
|
|
||||||
|
def media_url_with_type?(key, header_data)
|
||||||
|
key == 'media_url' && header_data['media_type'].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_body_components(processed_params, template)
|
||||||
|
return [] if processed_params['body'].blank?
|
||||||
|
|
||||||
|
body_params = processed_params['body'].filter_map do |key, value|
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
parameter_format = template['parameter_format']
|
parameter_format = template['parameter_format']
|
||||||
|
|
||||||
if parameter_format == 'NAMED'
|
if parameter_format == 'NAMED'
|
||||||
template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } }
|
parameter_builder.build_named_parameter(key, value)
|
||||||
else
|
else
|
||||||
template_params['processed_params']&.map { |_, value| { type: 'text', text: value } }
|
parameter_builder.build_parameter(value)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def validated_body_object(template)
|
body_params.present? ? [{ type: 'body', parameters: body_params }] : []
|
||||||
# we don't care if its not approved template
|
end
|
||||||
return if template['status'] != 'approved'
|
|
||||||
|
|
||||||
# we only care about text body object in template. if not present we discard the template
|
def process_footer_components(processed_params)
|
||||||
# we don't support other forms of templates
|
return [] if processed_params['footer'].blank?
|
||||||
template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') }
|
|
||||||
|
footer_params = processed_params['footer'].filter_map do |_, value|
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
|
parameter_builder.build_parameter(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
footer_params.present? ? [{ type: 'footer', parameters: footer_params }] : []
|
||||||
|
end
|
||||||
|
|
||||||
|
def process_button_components(processed_params)
|
||||||
|
return [] if processed_params['buttons'].blank?
|
||||||
|
|
||||||
|
button_params = processed_params['buttons'].filter_map.with_index do |button, index|
|
||||||
|
next if button.blank?
|
||||||
|
|
||||||
|
if button['type'] == 'url' || button['parameter'].present?
|
||||||
|
{
|
||||||
|
type: 'button',
|
||||||
|
sub_type: button['type'] || 'url',
|
||||||
|
index: index,
|
||||||
|
parameters: [parameter_builder.build_button_parameter(button)]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
button_params.compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def parameter_builder
|
||||||
|
@parameter_builder ||= Whatsapp::TemplateParameterService.new
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ describe Whatsapp::OneoffCampaignService do
|
|||||||
'namespace' => '23423423_2342423_324234234_2343224',
|
'namespace' => '23423423_2342423_324234234_2343224',
|
||||||
'category' => 'UTILITY',
|
'category' => 'UTILITY',
|
||||||
'language' => 'en',
|
'language' => 'en',
|
||||||
'processed_params' => { 'name' => 'John', 'ticket_id' => '2332' }
|
'processed_params' => { 'body' => { 'name' => 'John', 'ticket_id' => '2332' } }
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -124,12 +124,17 @@ describe Whatsapp::OneoffCampaignService do
|
|||||||
name: 'ticket_status_updated',
|
name: 'ticket_status_updated',
|
||||||
namespace: '23423423_2342423_324234234_2343224',
|
namespace: '23423423_2342423_324234234_2343224',
|
||||||
lang_code: 'en',
|
lang_code: 'en',
|
||||||
|
parameters: array_including(
|
||||||
|
hash_including(
|
||||||
|
type: 'body',
|
||||||
parameters: array_including(
|
parameters: array_including(
|
||||||
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
|
hash_including(type: 'text', parameter_name: 'name', text: 'John'),
|
||||||
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
|
hash_including(type: 'text', parameter_name: 'ticket_id', text: '2332')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
described_class.new(campaign: campaign).perform
|
described_class.new(campaign: campaign).perform
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -165,19 +165,17 @@ describe Whatsapp::Providers::WhatsappCloudService do
|
|||||||
let(:template_body) do
|
let(:template_body) do
|
||||||
{
|
{
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
|
recipient_type: 'individual', # Added recipient_type field
|
||||||
to: '+123456789',
|
to: '+123456789',
|
||||||
|
type: 'template',
|
||||||
template: {
|
template: {
|
||||||
name: template_info[:name],
|
name: template_info[:name],
|
||||||
language: {
|
language: {
|
||||||
policy: 'deterministic',
|
policy: 'deterministic',
|
||||||
code: template_info[:lang_code]
|
code: template_info[:lang_code]
|
||||||
},
|
},
|
||||||
components: [
|
components: template_info[:parameters] # Changed to use parameters directly (enhanced format)
|
||||||
{ type: 'body',
|
}
|
||||||
parameters: template_info[:parameters] }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
type: 'template'
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
namespace: '23423423_2342423_324234234_2343224',
|
namespace: '23423423_2342423_324234234_2343224',
|
||||||
language: 'en_US',
|
language: 'en_US',
|
||||||
category: 'Marketing',
|
category: 'Marketing',
|
||||||
processed_params: { '1' => '3' }
|
processed_params: { 'body' => { '1' => '3' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
describe '#perform' do
|
describe '#perform' do
|
||||||
@@ -39,15 +39,16 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
let(:named_template_body) do
|
let(:named_template_body) do
|
||||||
{
|
{
|
||||||
messaging_product: 'whatsapp',
|
messaging_product: 'whatsapp',
|
||||||
|
recipient_type: 'individual',
|
||||||
to: '123456789',
|
to: '123456789',
|
||||||
|
type: 'template',
|
||||||
template: {
|
template: {
|
||||||
name: 'ticket_status_updated',
|
name: 'ticket_status_updated',
|
||||||
language: { 'policy': 'deterministic', 'code': 'en_US' },
|
language: { 'policy': 'deterministic', 'code': 'en_US' },
|
||||||
components: [{ 'type': 'body',
|
components: [{ 'type': 'body',
|
||||||
'parameters': [{ 'type': 'text', parameter_name: 'last_name', 'text': 'Dale' },
|
'parameters': [{ 'type': 'text', parameter_name: 'last_name', 'text': 'Dale' },
|
||||||
{ 'type': 'text', parameter_name: 'ticket_id', 'text': '2332' }] }]
|
{ 'type': 'text', parameter_name: 'ticket_id', 'text': '2332' }] }]
|
||||||
},
|
}
|
||||||
type: 'template'
|
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
|
|
||||||
it 'calls channel.send_template when after 24 hour limit' do
|
it 'calls channel.send_template when after 24 hour limit' do
|
||||||
message = create(:message, message_type: :outgoing, content: 'Your package has been shipped. It will be delivered in 3 business days.',
|
message = create(:message, message_type: :outgoing, content: 'Your package has been shipped. It will be delivered in 3 business days.',
|
||||||
conversation: conversation)
|
conversation: conversation, additional_attributes: { template_params: template_params })
|
||||||
|
|
||||||
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
||||||
.with(
|
.with(
|
||||||
@@ -107,12 +108,18 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
name: 'ticket_status_updated',
|
name: 'ticket_status_updated',
|
||||||
language: 'en_US',
|
language: 'en_US',
|
||||||
category: 'UTILITY',
|
category: 'UTILITY',
|
||||||
processed_params: { 'last_name' => 'Dale', 'ticket_id' => '2332' }
|
processed_params: { 'body' => { 'last_name' => 'Dale', 'ticket_id' => '2332' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
stub_request(:post, "https://graph.facebook.com/v13.0/#{whatsapp_cloud_channel.provider_config['phone_number_id']}/messages")
|
stub_request(:post, "https://graph.facebook.com/v13.0/#{whatsapp_cloud_channel.provider_config['phone_number_id']}/messages")
|
||||||
.with(
|
.with(
|
||||||
:headers => { 'Content-Type' => 'application/json', 'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}" },
|
:headers => {
|
||||||
|
'Accept' => '*/*',
|
||||||
|
'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3',
|
||||||
|
'Content-Type' => 'application/json',
|
||||||
|
'Authorization' => "Bearer #{whatsapp_cloud_channel.provider_config['api_key']}",
|
||||||
|
'User-Agent' => 'Ruby'
|
||||||
|
},
|
||||||
:body => named_template_body.to_json
|
:body => named_template_body.to_json
|
||||||
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
|
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
|
||||||
message = create(:message,
|
message = create(:message,
|
||||||
@@ -124,12 +131,44 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'calls channel.send_template when template has regexp characters' do
|
it 'calls channel.send_template when template has regexp characters' do
|
||||||
message = create(
|
regexp_template_params = build_template_params('customer_yes_no', '2342384942_32423423_23423fdsdaf23', 'ar', {})
|
||||||
:message,
|
arabic_content = 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.'
|
||||||
message_type: :outgoing,
|
message = create_message_with_template(arabic_content, regexp_template_params)
|
||||||
content: 'عميلنا العزيز الرجاء الرد على هذه الرسالة بكلمة *نعم* للرد على إستفساركم من قبل خدمة العملاء.',
|
stub_template_request(regexp_template_params, [])
|
||||||
conversation: conversation
|
|
||||||
)
|
described_class.new(message: message).perform
|
||||||
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles template with header parameters' do
|
||||||
|
processed_params = {
|
||||||
|
'body' => { '1' => '3' },
|
||||||
|
'header' => { 'media_url' => 'https://example.com/image.jpg', 'media_type' => 'image' }
|
||||||
|
}
|
||||||
|
header_template_params = build_sample_template_params(processed_params)
|
||||||
|
message = create_message_with_template('', header_template_params)
|
||||||
|
|
||||||
|
components = [
|
||||||
|
{ type: 'header', parameters: [{ type: 'image', image: { link: 'https://example.com/image.jpg' } }] },
|
||||||
|
{ type: 'body', parameters: [{ type: 'text', text: '3' }] }
|
||||||
|
]
|
||||||
|
stub_sample_template_request(components)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles empty processed_params gracefully' do
|
||||||
|
empty_template_params = {
|
||||||
|
name: 'sample_shipping_confirmation',
|
||||||
|
namespace: '23423423_2342423_324234234_2343224',
|
||||||
|
language: 'en_US',
|
||||||
|
category: 'SHIPPING_UPDATE',
|
||||||
|
processed_params: {}
|
||||||
|
}
|
||||||
|
|
||||||
|
message = create(:message, additional_attributes: { template_params: empty_template_params },
|
||||||
|
conversation: conversation, message_type: :outgoing)
|
||||||
|
|
||||||
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
||||||
.with(
|
.with(
|
||||||
@@ -137,10 +176,10 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
body: {
|
body: {
|
||||||
to: '123456789',
|
to: '123456789',
|
||||||
template: {
|
template: {
|
||||||
name: 'customer_yes_no',
|
name: 'sample_shipping_confirmation',
|
||||||
namespace: '2342384942_32423423_23423fdsdaf23',
|
namespace: '23423423_2342423_324234234_2343224',
|
||||||
language: { 'policy': 'deterministic', 'code': 'ar' },
|
language: { 'policy': 'deterministic', 'code': 'en_US' },
|
||||||
components: [{ 'type': 'body', 'parameters': [] }]
|
components: []
|
||||||
},
|
},
|
||||||
type: 'template'
|
type: 'template'
|
||||||
}.to_json
|
}.to_json
|
||||||
@@ -149,6 +188,169 @@ describe Whatsapp::SendOnWhatsappService do
|
|||||||
described_class.new(message: message).perform
|
described_class.new(message: message).perform
|
||||||
expect(message.reload.source_id).to eq('123456789')
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it 'handles template with button parameters' do
|
||||||
|
processed_params = {
|
||||||
|
'body' => { '1' => '3' },
|
||||||
|
'buttons' => [{ 'type' => 'url', 'parameter' => 'https://track.example.com/123' }]
|
||||||
|
}
|
||||||
|
button_template_params = build_sample_template_params(processed_params)
|
||||||
|
message = create_message_with_template('', button_template_params)
|
||||||
|
|
||||||
|
components = [
|
||||||
|
{ type: 'body', parameters: [{ type: 'text', text: '3' }] },
|
||||||
|
{ type: 'button', sub_type: 'url', index: 0, parameters: [{ type: 'text', text: 'https://track.example.com/123' }] }
|
||||||
|
]
|
||||||
|
stub_sample_template_request(components)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'processes template parameters correctly via integration' do
|
||||||
|
processed_params = {
|
||||||
|
'body' => { '1' => '5' },
|
||||||
|
'footer' => { 'text' => 'Thank you' }
|
||||||
|
}
|
||||||
|
complex_template_params = build_sample_template_params(processed_params)
|
||||||
|
message = create_message_with_template('', complex_template_params)
|
||||||
|
|
||||||
|
components = [
|
||||||
|
{ type: 'body', parameters: [{ type: 'text', text: '5' }] },
|
||||||
|
{ type: 'footer', parameters: [{ type: 'text', text: 'Thank you' }] }
|
||||||
|
]
|
||||||
|
stub_sample_template_request(components)
|
||||||
|
|
||||||
|
expect { described_class.new(message: message).perform }.not_to raise_error
|
||||||
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles edge case with missing template gracefully' do
|
||||||
|
# Test the service behavior when template is not found
|
||||||
|
missing_template_params = {
|
||||||
|
'name' => 'non_existent_template',
|
||||||
|
'namespace' => 'missing_namespace',
|
||||||
|
'language' => 'en_US',
|
||||||
|
'category' => 'UTILITY',
|
||||||
|
'processed_params' => { 'body' => { '1' => 'test' } }
|
||||||
|
}
|
||||||
|
|
||||||
|
service = Whatsapp::TemplateProcessorService.new(
|
||||||
|
channel: whatsapp_channel,
|
||||||
|
template_params: missing_template_params
|
||||||
|
)
|
||||||
|
|
||||||
|
expect { service.call }.not_to raise_error
|
||||||
|
name, namespace, language, processed_params = service.call
|
||||||
|
expect(name).to eq('non_existent_template')
|
||||||
|
expect(namespace).to eq('missing_namespace')
|
||||||
|
expect(language).to eq('en_US')
|
||||||
|
expect(processed_params).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles template with blank parameter values correctly' do
|
||||||
|
processed_params = {
|
||||||
|
'body' => { '1' => '', '2' => 'valid_value', '3' => nil },
|
||||||
|
'header' => { 'media_url' => '', 'media_type' => 'image' }
|
||||||
|
}
|
||||||
|
blank_values_template_params = build_sample_template_params(processed_params)
|
||||||
|
message = create_message_with_template('', blank_values_template_params)
|
||||||
|
|
||||||
|
components = [{ type: 'body', parameters: [{ type: 'text', text: 'valid_value' }] }]
|
||||||
|
stub_sample_template_request(components)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'handles nil template_params gracefully' do
|
||||||
|
# Test service behavior when template_params is completely nil
|
||||||
|
message = create(:message, additional_attributes: {},
|
||||||
|
conversation: conversation, message_type: :outgoing)
|
||||||
|
|
||||||
|
# Should send regular message, not template
|
||||||
|
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
||||||
|
.with(
|
||||||
|
headers: headers,
|
||||||
|
body: {
|
||||||
|
to: '123456789',
|
||||||
|
text: { body: message.content },
|
||||||
|
type: 'text'
|
||||||
|
}.to_json
|
||||||
|
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
|
||||||
|
|
||||||
|
expect { described_class.new(message: message).perform }.not_to raise_error
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'processes template with rich text formatting' do
|
||||||
|
processed_params = { 'body' => { '1' => '*Bold text* and _italic text_' } }
|
||||||
|
rich_text_template_params = build_sample_template_params(processed_params)
|
||||||
|
message = create_message_with_template('', rich_text_template_params)
|
||||||
|
|
||||||
|
components = [{ type: 'body', parameters: [{ type: 'text', text: '*Bold text* and _italic text_' }] }]
|
||||||
|
stub_sample_template_request(components)
|
||||||
|
|
||||||
|
described_class.new(message: message).perform
|
||||||
|
expect(message.reload.source_id).to eq('123456789')
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_template_params(name, namespace, language, processed_params)
|
||||||
|
{
|
||||||
|
name: name,
|
||||||
|
namespace: namespace,
|
||||||
|
language: language,
|
||||||
|
category: 'SHIPPING_UPDATE',
|
||||||
|
processed_params: processed_params
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_message_with_template(content, template_params)
|
||||||
|
create(:message,
|
||||||
|
message_type: :outgoing,
|
||||||
|
content: content,
|
||||||
|
conversation: conversation,
|
||||||
|
additional_attributes: { template_params: template_params })
|
||||||
|
end
|
||||||
|
|
||||||
|
def stub_template_request(template_params, components)
|
||||||
|
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
||||||
|
.with(
|
||||||
|
headers: headers,
|
||||||
|
body: {
|
||||||
|
to: '123456789',
|
||||||
|
template: {
|
||||||
|
name: template_params[:name],
|
||||||
|
namespace: template_params[:namespace],
|
||||||
|
language: { 'policy': 'deterministic', 'code': template_params[:language] },
|
||||||
|
components: components
|
||||||
|
},
|
||||||
|
type: 'template'
|
||||||
|
}.to_json
|
||||||
|
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_sample_template_params(processed_params)
|
||||||
|
build_template_params('sample_shipping_confirmation', '23423423_2342423_324234234_2343224', 'en_US', processed_params)
|
||||||
|
end
|
||||||
|
|
||||||
|
def stub_sample_template_request(components)
|
||||||
|
stub_request(:post, 'https://waba.360dialog.io/v1/messages')
|
||||||
|
.with(
|
||||||
|
headers: headers,
|
||||||
|
body: {
|
||||||
|
to: '123456789',
|
||||||
|
template: {
|
||||||
|
name: 'sample_shipping_confirmation',
|
||||||
|
namespace: '23423423_2342423_324234234_2343224',
|
||||||
|
language: { 'policy': 'deterministic', 'code': 'en_US' },
|
||||||
|
components: components
|
||||||
|
},
|
||||||
|
type: 'template'
|
||||||
|
}.to_json
|
||||||
|
).to_return(status: 200, body: success_response, headers: { 'content-type' => 'application/json' })
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
describe Whatsapp::TemplateConverterService do
|
||||||
|
let(:template) do
|
||||||
|
{
|
||||||
|
'name' => 'test_template',
|
||||||
|
'language' => 'en',
|
||||||
|
'components' => [
|
||||||
|
{
|
||||||
|
'type' => 'BODY',
|
||||||
|
'text' => 'Hello {{1}}, your order {{2}} is ready!'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:media_template) do
|
||||||
|
{
|
||||||
|
'name' => 'media_template',
|
||||||
|
'language' => 'en',
|
||||||
|
'components' => [
|
||||||
|
{
|
||||||
|
'type' => 'HEADER',
|
||||||
|
'format' => 'IMAGE'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type' => 'BODY',
|
||||||
|
'text' => 'Check out {{1}}!'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
let(:button_template) do
|
||||||
|
{
|
||||||
|
'name' => 'button_template',
|
||||||
|
'language' => 'en',
|
||||||
|
'components' => [
|
||||||
|
{
|
||||||
|
'type' => 'BODY',
|
||||||
|
'text' => 'Visit our website!'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type' => 'BUTTONS',
|
||||||
|
'buttons' => [
|
||||||
|
{
|
||||||
|
'type' => 'URL',
|
||||||
|
'url' => 'https://example.com/{{1}}'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'type' => 'COPY_CODE'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#normalize_to_enhanced' do
|
||||||
|
context 'when already enhanced format' do
|
||||||
|
let(:enhanced_params) do
|
||||||
|
{
|
||||||
|
'processed_params' => {
|
||||||
|
'body' => { '1' => 'John', '2' => 'Order123' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unchanged' do
|
||||||
|
converter = described_class.new(enhanced_params, template)
|
||||||
|
result = converter.normalize_to_enhanced
|
||||||
|
expect(result).to eq(enhanced_params)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when legacy array format' do
|
||||||
|
let(:legacy_array_params) do
|
||||||
|
{
|
||||||
|
'processed_params' => %w[John Order123]
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts to enhanced format' do
|
||||||
|
converter = described_class.new(legacy_array_params, template)
|
||||||
|
result = converter.normalize_to_enhanced
|
||||||
|
|
||||||
|
expect(result['processed_params']).to eq({
|
||||||
|
'body' => { '1' => 'John', '2' => 'Order123' }
|
||||||
|
})
|
||||||
|
expect(result['format_version']).to eq('enhanced')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when legacy flat hash format' do
|
||||||
|
let(:legacy_hash_params) do
|
||||||
|
{
|
||||||
|
'processed_params' => { '1' => 'John', '2' => 'Order123' }
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts to enhanced format' do
|
||||||
|
converter = described_class.new(legacy_hash_params, template)
|
||||||
|
result = converter.normalize_to_enhanced
|
||||||
|
|
||||||
|
expect(result['processed_params']).to eq({
|
||||||
|
'body' => { '1' => 'John', '2' => 'Order123' }
|
||||||
|
})
|
||||||
|
expect(result['format_version']).to eq('enhanced')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when legacy hash with all body parameters' do
|
||||||
|
let(:legacy_hash_params) do
|
||||||
|
{
|
||||||
|
'processed_params' => {
|
||||||
|
'1' => 'Product',
|
||||||
|
'customer_name' => 'John'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts to enhanced format with body only' do
|
||||||
|
converter = described_class.new(legacy_hash_params, media_template)
|
||||||
|
result = converter.normalize_to_enhanced
|
||||||
|
|
||||||
|
expect(result['processed_params']).to eq({
|
||||||
|
'body' => {
|
||||||
|
'1' => 'Product',
|
||||||
|
'customer_name' => 'John'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
expect(result['format_version']).to eq('enhanced')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when invalid format' do
|
||||||
|
let(:invalid_params) do
|
||||||
|
{
|
||||||
|
'processed_params' => 'invalid_string'
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'raises ArgumentError' do
|
||||||
|
expect do
|
||||||
|
converter = described_class.new(invalid_params, template)
|
||||||
|
converter.normalize_to_enhanced
|
||||||
|
end.to raise_error(ArgumentError, /Unknown legacy format/)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#enhanced_format?' do
|
||||||
|
it 'returns true for valid enhanced format' do
|
||||||
|
enhanced = { 'body' => { '1' => 'test' } }
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
expect(converter.send(:enhanced_format?, enhanced)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for array' do
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
expect(converter.send(:enhanced_format?, ['test'])).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for flat hash' do
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
expect(converter.send(:enhanced_format?, { '1' => 'test' })).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns false for invalid structure' do
|
||||||
|
invalid = { 'body' => 'not_a_hash' }
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
expect(converter.send(:enhanced_format?, invalid)).to be false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe 'simplified conversion methods' do
|
||||||
|
describe '#convert_array_to_body_params' do
|
||||||
|
it 'converts empty array' do
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
result = converter.send(:convert_array_to_body_params, [])
|
||||||
|
expect(result).to eq({})
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'converts array to numbered body parameters' do
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
result = converter.send(:convert_array_to_body_params, %w[John Order123])
|
||||||
|
expect(result).to eq({ '1' => 'John', '2' => 'Order123' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe '#convert_hash_to_body_params' do
|
||||||
|
it 'converts hash to body parameters' do
|
||||||
|
converter = described_class.new({}, template)
|
||||||
|
result = converter.send(:convert_hash_to_body_params, { 'name' => 'John', 'order' => '123' })
|
||||||
|
expect(result).to eq({ 'name' => 'John', 'order' => '123' })
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user