feat(whatsapp): add template listing to settings (#15312)
Account administrators can now browse synced WhatsApp message templates inside Settings without opening a provider dashboard. The page combines native WhatsApp and Twilio WhatsApp templates, with channel and language filters, search, a template preview drawer, and links to manage templates at the provider. Creating, updating, and deleting templates in Chatwoot remains out of scope for this first version. Related: https://linear.app/chatwoot/issue/PLA-193/add-whatsapp-template-listing-to-account-settings <img width="2742" height="1460" alt="CleanShot 2026-08-03 at 20 23 27@2x" src="https://github.com/user-attachments/assets/5d0b7a98-f882-425a-b9ec-7d631371959c" /> ### Things to know - This is stack 2 of 2 and depends on API draft #15311. - Review this PR against `codex/whatsapp-template-api`; after the API PR lands, this branch can be rebased and retargeted to `develop`. - Both native WhatsApp and Twilio WhatsApp template caches are supported. - Template management remains in Meta Business Portfolio or Twilio Console. ### How to test 1. Open Settings → WhatsApp templates for an account with native and Twilio WhatsApp inboxes. 2. Confirm templates from each inbox are listed with their status, language, category, channel, and last-updated metadata. 3. Filter by channel and language, and search by template name or content. 4. Open a template and confirm its preview renders in the drawer. 5. Use the provider action and confirm it opens the appropriate Meta or Twilio template-management page. 6. Confirm loading, empty, and error states remain usable. --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com> Co-authored-by: iamsivin <iamsivin@gmail.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
@@ -33,6 +33,13 @@ class Inboxes extends CacheEnabledApiClient {
|
||||
return axios.post(`${this.url}/${inboxId}/sync_templates`);
|
||||
}
|
||||
|
||||
getMessageTemplates(inboxId, params = {}, config = {}) {
|
||||
return axios.get(`${this.url}/${inboxId}/message_templates`, {
|
||||
...config,
|
||||
params,
|
||||
});
|
||||
}
|
||||
|
||||
updateWhatsappBusinessManagementToken(inboxId, businessManagementToken) {
|
||||
return axios.put(
|
||||
`${this.url}/${inboxId}/whatsapp_business_management_token`,
|
||||
|
||||
@@ -13,6 +13,7 @@ describe('#InboxesAPI', () => {
|
||||
expect(inboxesAPI).toHaveProperty('getAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('setAgentBot');
|
||||
expect(inboxesAPI).toHaveProperty('syncTemplates');
|
||||
expect(inboxesAPI).toHaveProperty('getMessageTemplates');
|
||||
});
|
||||
|
||||
describe('API calls', () => {
|
||||
@@ -50,6 +51,24 @@ describe('#InboxesAPI', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('#getMessageTemplates', () => {
|
||||
const controller = new AbortController();
|
||||
|
||||
inboxesAPI.getMessageTemplates(
|
||||
2,
|
||||
{ name: 'welcome' },
|
||||
{ signal: controller.signal }
|
||||
);
|
||||
|
||||
expect(axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v1/inboxes/2/message_templates',
|
||||
{
|
||||
signal: controller.signal,
|
||||
params: { name: 'welcome' },
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#updateWhatsappBusinessManagementToken', () => {
|
||||
inboxesAPI.updateWhatsappBusinessManagementToken(2, 'business-token');
|
||||
expect(axiosMock.put).toHaveBeenCalledWith(
|
||||
|
||||
@@ -840,6 +840,12 @@ const menuItems = computed(() => {
|
||||
],
|
||||
to: accountScopedRoute('settings_inbox_list'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Templates',
|
||||
label: t('SIDEBAR.WHATSAPP_TEMPLATES'),
|
||||
icon: 'i-lucide-layout-template',
|
||||
to: accountScopedRoute('settings_templates'),
|
||||
},
|
||||
{
|
||||
name: 'Settings Labels',
|
||||
label: t('SIDEBAR.LABELS'),
|
||||
|
||||
@@ -38,6 +38,7 @@ import sla from './sla.json';
|
||||
import snooze from './snooze.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import whatsappTemplateMgmt from './whatsappTemplateMgmt.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
import mfa from './mfa.json';
|
||||
import onboarding from './onboarding.json';
|
||||
@@ -85,6 +86,7 @@ export default {
|
||||
...snooze,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...whatsappTemplateMgmt,
|
||||
...contentTemplates,
|
||||
...mfa,
|
||||
...onboarding,
|
||||
|
||||
@@ -340,6 +340,7 @@
|
||||
"AGENT_BOTS": "Bots",
|
||||
"AUDIT_LOGS": "Audit Logs",
|
||||
"INBOXES": "Inboxes",
|
||||
"WHATSAPP_TEMPLATES": "Templates",
|
||||
"NOTIFICATIONS": "Notifications",
|
||||
"CANNED_RESPONSES": "Canned Responses",
|
||||
"INTEGRATIONS": "Integrations",
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"WHATSAPP_TEMPLATE_MGMT": {
|
||||
"TITLE": "Templates",
|
||||
"DESCRIPTION": "View the message templates synced from your WhatsApp inboxes. To create or edit a template, manage it with your provider.",
|
||||
"COUNT": "{n} template | {n} templates",
|
||||
"KNOW_MORE": "Know more",
|
||||
"NEW_TEMPLATE": "New template",
|
||||
"SEARCH_PLACEHOLDER": "Search by content or name...",
|
||||
"MANAGE_IN_META": "Manage in Meta",
|
||||
"MANAGE_IN_TWILIO": "Manage in Twilio",
|
||||
"LOADING": "Loading WhatsApp templates...",
|
||||
"EMPTY": "No WhatsApp templates found.",
|
||||
"NO_RESULTS": "No templates match your filters.",
|
||||
"FETCH_ERROR": "Unable to load WhatsApp templates. Please try again.",
|
||||
"PARTIAL_FETCH_ERROR": "Some WhatsApp templates could not be loaded. Please try again.",
|
||||
"LAST_SYNC_ATTEMPT": "Last sync attempt on {date}",
|
||||
"MORE_INBOXES": "+{n}",
|
||||
"FILTERS": {
|
||||
"ALL_INBOXES": "All inboxes",
|
||||
"ALL_LANGUAGES": "All languages"
|
||||
},
|
||||
"TYPES": {
|
||||
"TEXT": "Text",
|
||||
"IMAGE": "Image",
|
||||
"VIDEO": "Video",
|
||||
"DOCUMENT": "Document",
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick reply",
|
||||
"CALL_TO_ACTION": "Call to action",
|
||||
"CATALOG": "Catalog",
|
||||
"COPY_CODE": "Copy code"
|
||||
},
|
||||
"PREVIEW": {
|
||||
"TITLE": "Template preview",
|
||||
"DESCRIPTION": "Preview how this template appears in WhatsApp.",
|
||||
"OPEN": "Preview {name}",
|
||||
"CLOSE": "Close preview",
|
||||
"DETAILS": "Template details",
|
||||
"STATUS": "Status",
|
||||
"TYPE": "Content type",
|
||||
"CATEGORY": "Category",
|
||||
"LANGUAGE": "Language",
|
||||
"INBOXES": "Inboxes",
|
||||
"LAST_SYNC_ATTEMPT": "Last sync attempt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -119,7 +119,7 @@ const helpURL = getHelpUrlForFeature(props.featureName);
|
||||
>
|
||||
<slot name="count" />
|
||||
<div
|
||||
v-if="slots.count"
|
||||
v-if="slots.count && slots.actions"
|
||||
class="w-px h-3 rounded-lg bg-n-weak ltr:ml-1 ltr:mr-2 rtl:ml-2 rtl:mr-1 flex-shrink-0"
|
||||
/>
|
||||
<slot name="actions" />
|
||||
|
||||
@@ -14,6 +14,7 @@ import auditlogs from './auditlogs/audit.routes';
|
||||
import billing from './billing/billing.routes';
|
||||
import canned from './canned/canned.routes';
|
||||
import inbox from './inbox/inbox.routes';
|
||||
import templates from './templates/templates.routes';
|
||||
import integrations from './integrations/integrations.routes';
|
||||
import labels from './labels/labels.routes';
|
||||
import macros from './macros/macros.routes';
|
||||
@@ -57,6 +58,7 @@ export default {
|
||||
...billing.routes,
|
||||
...canned.routes,
|
||||
...inbox.routes,
|
||||
...templates.routes,
|
||||
...integrations.routes,
|
||||
...data.routes,
|
||||
...labels.routes,
|
||||
|
||||
@@ -0,0 +1,391 @@
|
||||
<script setup>
|
||||
import { computed, onActivated, onDeactivated, ref } from 'vue';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { vOnClickOutside } from '@vueuse/components';
|
||||
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { useAbortableRequest } from 'dashboard/composables/useAbortableRequest';
|
||||
import { INBOX_TYPES, TWILIO_CHANNEL_MEDIUM } from 'dashboard/helper/inbox';
|
||||
import InboxesAPI from 'dashboard/api/inboxes';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import TemplateCard from './TemplateCard.vue';
|
||||
import TemplatePreviewDrawer from './TemplatePreviewDrawer.vue';
|
||||
import { formatTemplateLanguage, groupTemplates } from './templateUtils';
|
||||
|
||||
const FUZZY_SEARCH_KEYS = [
|
||||
{ name: 'name', weight: 4 },
|
||||
'category',
|
||||
'language',
|
||||
'status',
|
||||
'inboxNames',
|
||||
'searchableContent',
|
||||
];
|
||||
|
||||
const META_TEMPLATE_MANAGER_URL =
|
||||
'https://business.facebook.com/latest/whatsapp_manager/message_templates';
|
||||
const TWILIO_TEMPLATE_MANAGER_URL =
|
||||
'https://console.twilio.com/us1/develop/sms/content-editor';
|
||||
const TEMPLATE_LEARN_MORE_URL =
|
||||
'https://www.chatwoot.com/hc/user-guide/articles/1754940076-whatsapp-templates';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const templates = ref([]);
|
||||
const searchQuery = ref('');
|
||||
const selectedInboxId = ref('all');
|
||||
const selectedLanguage = ref('all');
|
||||
const selectedTemplate = ref(null);
|
||||
const openFilterMenu = ref(null);
|
||||
const previewPanelRef = ref(null);
|
||||
const templateRecordsByInboxId = new Map();
|
||||
const {
|
||||
run: runTemplateRequest,
|
||||
abort: abortTemplateRequest,
|
||||
isPending: isLoading,
|
||||
} = useAbortableRequest();
|
||||
|
||||
const hasTemplates = computed(() => templates.value.length > 0);
|
||||
|
||||
const whatsappInboxes = computed(() =>
|
||||
inboxes.value.filter(
|
||||
inbox =>
|
||||
inbox.channel_type === INBOX_TYPES.WHATSAPP ||
|
||||
(inbox.channel_type === INBOX_TYPES.TWILIO &&
|
||||
inbox.medium === TWILIO_CHANNEL_MEDIUM.WHATSAPP)
|
||||
)
|
||||
);
|
||||
|
||||
const canManageInMeta = computed(() =>
|
||||
whatsappInboxes.value.some(inbox => inbox.provider === 'whatsapp_cloud')
|
||||
);
|
||||
|
||||
const hasTwilioInboxes = computed(() =>
|
||||
whatsappInboxes.value.some(inbox => inbox.channel_type === INBOX_TYPES.TWILIO)
|
||||
);
|
||||
|
||||
const hasUnsupportedWhatsappInboxes = computed(() =>
|
||||
whatsappInboxes.value.some(
|
||||
inbox =>
|
||||
inbox.channel_type === INBOX_TYPES.WHATSAPP &&
|
||||
inbox.provider !== 'whatsapp_cloud'
|
||||
)
|
||||
);
|
||||
|
||||
const selectedInbox = computed(() =>
|
||||
whatsappInboxes.value.find(
|
||||
inbox => String(inbox.id) === selectedInboxId.value
|
||||
)
|
||||
);
|
||||
|
||||
const newTemplateUrl = computed(() => {
|
||||
if (selectedInbox.value) {
|
||||
if (selectedInbox.value.channel_type === INBOX_TYPES.TWILIO) {
|
||||
return TWILIO_TEMPLATE_MANAGER_URL;
|
||||
}
|
||||
|
||||
return selectedInbox.value.provider === 'whatsapp_cloud'
|
||||
? META_TEMPLATE_MANAGER_URL
|
||||
: null;
|
||||
}
|
||||
|
||||
if (
|
||||
canManageInMeta.value &&
|
||||
!hasTwilioInboxes.value &&
|
||||
!hasUnsupportedWhatsappInboxes.value
|
||||
) {
|
||||
return META_TEMPLATE_MANAGER_URL;
|
||||
}
|
||||
|
||||
return hasTwilioInboxes.value &&
|
||||
!canManageInMeta.value &&
|
||||
!hasUnsupportedWhatsappInboxes.value
|
||||
? TWILIO_TEMPLATE_MANAGER_URL
|
||||
: null;
|
||||
});
|
||||
|
||||
const inboxOptions = computed(() => [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('WHATSAPP_TEMPLATE_MGMT.FILTERS.ALL_INBOXES'),
|
||||
},
|
||||
...whatsappInboxes.value.map(inbox => ({
|
||||
value: String(inbox.id),
|
||||
label: inbox.name,
|
||||
})),
|
||||
]);
|
||||
|
||||
const languageOptions = computed(() => [
|
||||
{
|
||||
value: 'all',
|
||||
label: t('WHATSAPP_TEMPLATE_MGMT.FILTERS.ALL_LANGUAGES'),
|
||||
},
|
||||
...[...new Set(templates.value.map(template => template.language))]
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.map(language => ({
|
||||
value: language,
|
||||
label: formatTemplateLanguage(language),
|
||||
})),
|
||||
]);
|
||||
|
||||
const filterMenus = computed(() =>
|
||||
[
|
||||
{
|
||||
key: 'inbox',
|
||||
icon: 'i-lucide-inbox',
|
||||
options: inboxOptions.value,
|
||||
active: selectedInboxId.value,
|
||||
},
|
||||
{
|
||||
key: 'language',
|
||||
icon: 'i-lucide-languages',
|
||||
options: languageOptions.value,
|
||||
active: selectedLanguage.value,
|
||||
},
|
||||
].map(menu => {
|
||||
const items = menu.options.map(option => ({
|
||||
...option,
|
||||
action: menu.key,
|
||||
isSelected: option.value === menu.active,
|
||||
}));
|
||||
|
||||
return {
|
||||
...menu,
|
||||
items,
|
||||
selected: items.find(item => item.isSelected) || items[0],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const closeFilterMenu = () => {
|
||||
openFilterMenu.value = null;
|
||||
};
|
||||
|
||||
const toggleFilterMenu = key => {
|
||||
openFilterMenu.value = openFilterMenu.value === key ? null : key;
|
||||
};
|
||||
|
||||
const openPreview = template => {
|
||||
selectedTemplate.value = template;
|
||||
previewPanelRef.value?.open();
|
||||
};
|
||||
|
||||
const handleFilterAction = ({ action, value }) => {
|
||||
closeFilterMenu();
|
||||
if (action === 'inbox') selectedInboxId.value = value;
|
||||
else selectedLanguage.value = value;
|
||||
};
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
let records = templates.value;
|
||||
|
||||
if (selectedInboxId.value !== 'all') {
|
||||
records = records.filter(template =>
|
||||
template.inboxes.some(inbox => String(inbox.id) === selectedInboxId.value)
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedLanguage.value !== 'all') {
|
||||
records = records.filter(
|
||||
template => template.language === selectedLanguage.value
|
||||
);
|
||||
}
|
||||
|
||||
const query = searchQuery.value.trim();
|
||||
if (!query) return records;
|
||||
|
||||
const normalizedQuery = query.toLowerCase();
|
||||
const contentMatches = records.filter(template =>
|
||||
[template.name, template.searchableContent].some(value =>
|
||||
value?.toLowerCase().includes(normalizedQuery)
|
||||
)
|
||||
);
|
||||
if (contentMatches.length) return contentMatches;
|
||||
|
||||
return picoSearch(records, query, FUZZY_SEARCH_KEYS);
|
||||
});
|
||||
|
||||
const showSearch = computed(() =>
|
||||
Boolean(filteredTemplates.value.length || searchQuery.value)
|
||||
);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
await runTemplateRequest(async signal => {
|
||||
const didFetchInboxes = await store.dispatch('inboxes/get');
|
||||
if (!didFetchInboxes) throw new Error();
|
||||
if (signal.aborted) return;
|
||||
|
||||
const inboxesToFetch = [...whatsappInboxes.value];
|
||||
const responses = await Promise.allSettled(
|
||||
inboxesToFetch.map(async inbox => {
|
||||
const { data } = await InboxesAPI.getMessageTemplates(
|
||||
inbox.id,
|
||||
{},
|
||||
{ signal }
|
||||
);
|
||||
|
||||
if (!Array.isArray(data.payload)) {
|
||||
throw new TypeError();
|
||||
}
|
||||
|
||||
return {
|
||||
inboxId: inbox.id,
|
||||
records: data.payload.map(template => ({
|
||||
template,
|
||||
inbox,
|
||||
lastUpdatedAt: data.meta?.last_sync_attempt_at,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
if (signal.aborted) return;
|
||||
|
||||
const successfulResponses = responses.filter(
|
||||
response => response.status === 'fulfilled'
|
||||
);
|
||||
const activeInboxIds = new Set(inboxesToFetch.map(inbox => inbox.id));
|
||||
|
||||
templateRecordsByInboxId.forEach((_, inboxId) => {
|
||||
if (!activeInboxIds.has(inboxId))
|
||||
templateRecordsByInboxId.delete(inboxId);
|
||||
});
|
||||
successfulResponses.forEach(({ value }) => {
|
||||
templateRecordsByInboxId.set(value.inboxId, value.records);
|
||||
});
|
||||
templates.value = groupTemplates(
|
||||
[...templateRecordsByInboxId.values()].flat()
|
||||
);
|
||||
|
||||
if (
|
||||
!inboxOptions.value.some(({ value }) => value === selectedInboxId.value)
|
||||
)
|
||||
selectedInboxId.value = 'all';
|
||||
if (
|
||||
!languageOptions.value.some(
|
||||
({ value }) => value === selectedLanguage.value
|
||||
)
|
||||
)
|
||||
selectedLanguage.value = 'all';
|
||||
|
||||
if (responses.some(response => response.status === 'rejected')) {
|
||||
const errorMessage = successfulResponses.length
|
||||
? t('WHATSAPP_TEMPLATE_MGMT.PARTIAL_FETCH_ERROR')
|
||||
: t('WHATSAPP_TEMPLATE_MGMT.FETCH_ERROR');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
useAlert(t('WHATSAPP_TEMPLATE_MGMT.FETCH_ERROR'));
|
||||
}
|
||||
};
|
||||
|
||||
onActivated(fetchTemplates);
|
||||
onDeactivated(abortTemplateRequest);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SettingsLayout
|
||||
:is-loading="isLoading"
|
||||
:loading-message="$t('WHATSAPP_TEMPLATE_MGMT.LOADING')"
|
||||
:no-records-found="!templates.length"
|
||||
:no-records-message="$t('WHATSAPP_TEMPLATE_MGMT.EMPTY')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
v-model:search-query="searchQuery"
|
||||
:title="$t('WHATSAPP_TEMPLATE_MGMT.TITLE')"
|
||||
:search-placeholder="
|
||||
showSearch ? $t('WHATSAPP_TEMPLATE_MGMT.SEARCH_PLACEHOLDER') : ''
|
||||
"
|
||||
>
|
||||
<template #description>
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.DESCRIPTION') }}
|
||||
<a
|
||||
:href="TEMPLATE_LEARN_MORE_URL"
|
||||
class="text-sm font-medium text-n-blue-11 hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.KNOW_MORE') }}
|
||||
</a>
|
||||
</template>
|
||||
<template #tabs>
|
||||
<div
|
||||
v-if="hasTemplates"
|
||||
v-on-click-outside="closeFilterMenu"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<div v-for="menu in filterMenus" :key="menu.key" class="relative">
|
||||
<Button
|
||||
:icon="menu.icon"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:class="{ 'bg-n-slate-9/10': openFilterMenu === menu.key }"
|
||||
@click="toggleFilterMenu(menu.key)"
|
||||
>
|
||||
<span class="min-w-0 truncate">{{ menu.selected.label }}</span>
|
||||
<Icon icon="i-lucide-chevron-down" class="shrink-0 size-4" />
|
||||
</Button>
|
||||
<DropdownMenu
|
||||
v-if="openFilterMenu === menu.key"
|
||||
:menu-items="menu.items"
|
||||
class="mt-2 min-w-52 top-full ltr:left-0 rtl:right-0"
|
||||
@action="handleFilterAction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="filteredTemplates.length" #count>
|
||||
<span class="text-body-main text-n-slate-11">
|
||||
{{
|
||||
$t('WHATSAPP_TEMPLATE_MGMT.COUNT', {
|
||||
n: filteredTemplates.length,
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
<template v-if="newTemplateUrl" #actions>
|
||||
<a :href="newTemplateUrl" target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
:label="$t('WHATSAPP_TEMPLATE_MGMT.NEW_TEMPLATE')"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div
|
||||
v-if="!filteredTemplates.length"
|
||||
class="flex items-center justify-center p-8"
|
||||
>
|
||||
<span class="text-base text-n-slate-11">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.NO_RESULTS') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-else class="border-t divide-y divide-n-weak border-n-weak">
|
||||
<TemplateCard
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.key"
|
||||
:template="template"
|
||||
@preview="openPreview(template)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<TemplatePreviewDrawer ref="previewPanelRef" :template="selectedTemplate" />
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import ChannelIcon from 'dashboard/components-next/icon/ChannelIcon.vue';
|
||||
import {
|
||||
formatTemplateDate,
|
||||
formatTemplateLabel,
|
||||
formatTemplateLanguage,
|
||||
templateStatusClasses,
|
||||
templateTypeKey,
|
||||
} from './templateUtils';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['preview']);
|
||||
|
||||
const showStatus = computed(
|
||||
() => props.template.status?.toLowerCase() !== 'approved'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between gap-4 py-4 cursor-pointer group"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="emit('preview')"
|
||||
@keydown.enter="emit('preview')"
|
||||
@keydown.space.prevent="emit('preview')"
|
||||
>
|
||||
<div class="flex items-center min-w-0 gap-3">
|
||||
<span
|
||||
class="grid border rounded-xl shadow-sm size-10 shrink-0 place-items-center bg-n-alpha-3 border-n-strong ring ring-n-solid-1"
|
||||
>
|
||||
<ChannelIcon
|
||||
:inbox="template.inboxes[0]"
|
||||
class="size-5 text-n-slate-11"
|
||||
/>
|
||||
</span>
|
||||
<div class="flex flex-col min-w-0 gap-1">
|
||||
<div class="flex items-center min-w-0 gap-2">
|
||||
<span class="truncate text-heading-3 text-n-slate-12">
|
||||
{{ template.name }}
|
||||
</span>
|
||||
<span
|
||||
v-if="showStatus"
|
||||
class="inline-flex shrink-0 px-2 py-0.5 text-xs font-medium rounded-md"
|
||||
:class="templateStatusClasses(template.status)"
|
||||
>
|
||||
{{ formatTemplateLabel(template.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-2 text-body-main text-n-slate-11"
|
||||
>
|
||||
<span>
|
||||
{{
|
||||
$t(`WHATSAPP_TEMPLATE_MGMT.TYPES.${templateTypeKey(template)}`)
|
||||
}}
|
||||
</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<span>{{ formatTemplateLanguage(template.language) }}</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<span class="truncate">{{ template.inboxNames }}</span>
|
||||
<div class="w-px h-3 rounded-lg bg-n-strong" />
|
||||
<span class="whitespace-nowrap">
|
||||
{{
|
||||
$t('WHATSAPP_TEMPLATE_MGMT.LAST_SYNC_ATTEMPT', {
|
||||
date: formatTemplateDate(template.lastUpdatedAt),
|
||||
})
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip.top="$t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.TITLE')"
|
||||
icon="i-lucide-eye"
|
||||
color="slate"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:aria-label="
|
||||
$t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.OPEN', { name: template.name })
|
||||
"
|
||||
@click.stop="emit('preview')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,143 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import SidePanel from 'dashboard/components-next/side-panel/SidePanel.vue';
|
||||
import {
|
||||
TemplateNormalizer,
|
||||
TemplatePreview,
|
||||
} from 'dashboard/components-next/template-preview';
|
||||
import { PLATFORMS } from 'dashboard/services/TemplateConstants';
|
||||
import {
|
||||
formatTemplateDate,
|
||||
formatTemplateLabel,
|
||||
formatTemplateLanguage,
|
||||
templateStatusClasses,
|
||||
templateTypeKey,
|
||||
} from './templateUtils';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: null,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
const META_TEMPLATE_MANAGER_URL =
|
||||
'https://business.facebook.com/latest/whatsapp_manager/message_templates';
|
||||
const TWILIO_TEMPLATE_MANAGER_URL =
|
||||
'https://console.twilio.com/us1/develop/sms/content-editor';
|
||||
|
||||
const panelRef = ref(null);
|
||||
|
||||
const platform = computed(() => props.template?.platform || PLATFORMS.WHATSAPP);
|
||||
const normalizedTemplate = computed(() =>
|
||||
props.template
|
||||
? TemplateNormalizer.normalize(props.template, platform.value)
|
||||
: null
|
||||
);
|
||||
const variables = computed(() => normalizedTemplate.value?.variables || {});
|
||||
const managementUrl = computed(() => {
|
||||
if (platform.value === PLATFORMS.TWILIO) {
|
||||
return TWILIO_TEMPLATE_MANAGER_URL;
|
||||
}
|
||||
|
||||
return props.template?.inboxes?.some(
|
||||
inbox => inbox.provider === 'whatsapp_cloud'
|
||||
)
|
||||
? META_TEMPLATE_MANAGER_URL
|
||||
: null;
|
||||
});
|
||||
const managementLabel = computed(() =>
|
||||
platform.value === PLATFORMS.TWILIO
|
||||
? t('WHATSAPP_TEMPLATE_MGMT.MANAGE_IN_TWILIO')
|
||||
: t('WHATSAPP_TEMPLATE_MGMT.MANAGE_IN_META')
|
||||
);
|
||||
const open = () => panelRef.value?.open();
|
||||
const close = () => panelRef.value?.close();
|
||||
|
||||
defineExpose({ open, close });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SidePanel
|
||||
ref="panelRef"
|
||||
width="md"
|
||||
:title="template?.name"
|
||||
:description="$t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.DESCRIPTION')"
|
||||
>
|
||||
<div v-if="template" class="flex flex-col gap-6">
|
||||
<div
|
||||
class="flex items-center justify-center px-6 py-10 border rounded-xl min-h-80 border-n-weak bg-n-alpha-1"
|
||||
>
|
||||
<TemplatePreview
|
||||
:template="template"
|
||||
:variables="variables"
|
||||
:platform="platform"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.DETAILS') }}
|
||||
</h3>
|
||||
<dl class="grid grid-cols-[8rem_1fr] gap-x-4 gap-y-3 mt-4 text-sm">
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.STATUS') }}
|
||||
</dt>
|
||||
<dd>
|
||||
<span
|
||||
class="inline-flex px-2 py-0.5 text-xs font-medium rounded-md"
|
||||
:class="templateStatusClasses(template.status)"
|
||||
>
|
||||
{{ formatTemplateLabel(template.status) }}
|
||||
</span>
|
||||
</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.TYPE') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">
|
||||
{{
|
||||
$t(`WHATSAPP_TEMPLATE_MGMT.TYPES.${templateTypeKey(template)}`)
|
||||
}}
|
||||
</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.CATEGORY') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">
|
||||
{{ formatTemplateLabel(template.category) }}
|
||||
</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.LANGUAGE') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">
|
||||
{{ formatTemplateLanguage(template.language) }}
|
||||
</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.INBOXES') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">{{ template.inboxNames }}</dd>
|
||||
<dt class="text-n-slate-10">
|
||||
{{ $t('WHATSAPP_TEMPLATE_MGMT.PREVIEW.LAST_SYNC_ATTEMPT') }}
|
||||
</dt>
|
||||
<dd class="text-n-slate-12">
|
||||
{{ formatTemplateDate(template.lastUpdatedAt) }}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="managementUrl" #footer>
|
||||
<a :href="managementUrl" target="_blank" rel="noopener noreferrer">
|
||||
<Button
|
||||
class="w-full"
|
||||
:label="managementLabel"
|
||||
icon="i-lucide-external-link"
|
||||
trailing-icon
|
||||
/>
|
||||
</a>
|
||||
</template>
|
||||
</SidePanel>
|
||||
</template>
|
||||
@@ -0,0 +1,146 @@
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { groupTemplates } from '../templateUtils';
|
||||
|
||||
const whatsappInbox = ({
|
||||
id,
|
||||
name,
|
||||
businessAccountId,
|
||||
provider = 'whatsapp_cloud',
|
||||
}) => ({
|
||||
id,
|
||||
name,
|
||||
channel_type: INBOX_TYPES.WHATSAPP,
|
||||
provider,
|
||||
provider_config: { business_account_id: businessAccountId },
|
||||
});
|
||||
|
||||
describe('#groupTemplates', () => {
|
||||
it('uses the newest cache fields when grouping a shared provider template', () => {
|
||||
const olderInbox = whatsappInbox({
|
||||
id: 1,
|
||||
name: 'Older inbox',
|
||||
businessAccountId: 'waba-1',
|
||||
});
|
||||
const newerInbox = whatsappInbox({
|
||||
id: 2,
|
||||
name: 'Newer inbox',
|
||||
businessAccountId: 'waba-1',
|
||||
});
|
||||
|
||||
const result = groupTemplates([
|
||||
{
|
||||
inbox: olderInbox,
|
||||
lastUpdatedAt: '2026-08-01T10:00:00.000Z',
|
||||
template: {
|
||||
id: 'template-1',
|
||||
name: 'order_update',
|
||||
language: 'en_US',
|
||||
status: 'pending',
|
||||
components: [{ type: 'BODY', text: 'Old body' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
inbox: newerInbox,
|
||||
lastUpdatedAt: '2026-08-02T10:00:00.000Z',
|
||||
template: {
|
||||
id: 'template-1',
|
||||
name: 'order_update',
|
||||
language: 'en_US',
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'New body' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
status: 'approved',
|
||||
components: [{ type: 'BODY', text: 'New body' }],
|
||||
inboxNames: 'Older inbox, Newer inbox',
|
||||
lastUpdatedAt: '2026-08-02T10:00:00.000Z',
|
||||
});
|
||||
expect(result[0].inboxes).toEqual([olderInbox, newerInbox]);
|
||||
});
|
||||
|
||||
it('keeps delimiter-containing provider identities separate', () => {
|
||||
const result = groupTemplates([
|
||||
{
|
||||
inbox: whatsappInbox({
|
||||
id: 1,
|
||||
name: 'Delimiter A',
|
||||
businessAccountId: 'account:a',
|
||||
}),
|
||||
template: {
|
||||
id: 'template',
|
||||
name: 'delimiter_a',
|
||||
language: 'en_US',
|
||||
},
|
||||
},
|
||||
{
|
||||
inbox: whatsappInbox({
|
||||
id: 2,
|
||||
name: 'Delimiter B',
|
||||
businessAccountId: 'account',
|
||||
}),
|
||||
template: {
|
||||
id: 'a:template',
|
||||
name: 'delimiter_b',
|
||||
language: 'en_US',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(template => template.inboxes)).toEqual([
|
||||
[
|
||||
whatsappInbox({
|
||||
id: 1,
|
||||
name: 'Delimiter A',
|
||||
businessAccountId: 'account:a',
|
||||
}),
|
||||
],
|
||||
[
|
||||
whatsappInbox({
|
||||
id: 2,
|
||||
name: 'Delimiter B',
|
||||
businessAccountId: 'account',
|
||||
}),
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('scopes templates without provider identities to their inbox and content', () => {
|
||||
const result = groupTemplates([
|
||||
{
|
||||
inbox: whatsappInbox({
|
||||
id: 1,
|
||||
name: 'First inbox',
|
||||
businessAccountId: 'waba-1',
|
||||
}),
|
||||
template: {
|
||||
name: 'shared_name',
|
||||
language: 'en_US',
|
||||
components: [{ type: 'BODY', text: 'First body' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
inbox: whatsappInbox({
|
||||
id: 2,
|
||||
name: 'Second inbox',
|
||||
businessAccountId: 'waba-1',
|
||||
}),
|
||||
template: {
|
||||
name: 'shared_name',
|
||||
language: 'en_US',
|
||||
components: [{ type: 'BODY', text: 'Second body' }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(template => template.searchableContent)).toEqual([
|
||||
'[{"type":"BODY","text":"First body"}]',
|
||||
'[{"type":"BODY","text":"Second body"}]',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
import { TemplateTypeDetector } from 'dashboard/services/TemplateTypeDetector';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import {
|
||||
PLATFORMS,
|
||||
TEMPLATE_TYPES,
|
||||
} from 'dashboard/services/TemplateConstants';
|
||||
|
||||
const TEMPLATE_TYPE_KEYS = {
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT]: 'TEXT',
|
||||
[TEMPLATE_TYPES.WHATSAPP_TEXT_HEADER]: 'TEXT',
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_IMAGE]: 'IMAGE',
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_VIDEO]: 'VIDEO',
|
||||
[TEMPLATE_TYPES.WHATSAPP_MEDIA_DOCUMENT]: 'DOCUMENT',
|
||||
[TEMPLATE_TYPES.WHATSAPP_INTERACTIVE]: 'CALL_TO_ACTION',
|
||||
[TEMPLATE_TYPES.WHATSAPP_COPY_CODE]: 'COPY_CODE',
|
||||
[TEMPLATE_TYPES.TWILIO_TEXT]: 'TEXT',
|
||||
[TEMPLATE_TYPES.TWILIO_MEDIA]: 'MEDIA',
|
||||
[TEMPLATE_TYPES.TWILIO_QUICK_REPLY]: 'QUICK_REPLY',
|
||||
[TEMPLATE_TYPES.TWILIO_CALL_TO_ACTION]: 'CALL_TO_ACTION',
|
||||
[TEMPLATE_TYPES.TWILIO_CARD]: 'CATALOG',
|
||||
};
|
||||
|
||||
export const groupTemplates = templateRecords => {
|
||||
const groupedTemplates = new Map();
|
||||
|
||||
templateRecords.forEach(({ template, inbox, lastUpdatedAt }) => {
|
||||
const platform =
|
||||
inbox.channel_type === INBOX_TYPES.TWILIO
|
||||
? PLATFORMS.TWILIO
|
||||
: PLATFORMS.WHATSAPP;
|
||||
const providerAccountId =
|
||||
inbox.provider_config?.business_account_id ||
|
||||
inbox.account_sid ||
|
||||
inbox.id;
|
||||
const name = template.name || template.friendly_name;
|
||||
const providerTemplateIdentifier = template.id || template.content_sid;
|
||||
const templateIdentifier = providerTemplateIdentifier || name;
|
||||
const key = JSON.stringify(
|
||||
providerTemplateIdentifier
|
||||
? [
|
||||
platform,
|
||||
providerAccountId,
|
||||
providerTemplateIdentifier,
|
||||
template.language,
|
||||
]
|
||||
: [platform, inbox.id, name, template.language, template]
|
||||
);
|
||||
const searchableContent = JSON.stringify(
|
||||
template.components || template.types || template.body || []
|
||||
);
|
||||
const normalizedTemplate = {
|
||||
...template,
|
||||
id: templateIdentifier,
|
||||
name,
|
||||
platform,
|
||||
key,
|
||||
inboxes: [inbox],
|
||||
inboxNames: inbox.name,
|
||||
lastUpdatedAt,
|
||||
searchableContent,
|
||||
};
|
||||
const existingTemplate = groupedTemplates.get(key);
|
||||
|
||||
if (existingTemplate) {
|
||||
const inboxes = [...existingTemplate.inboxes, inbox];
|
||||
const incomingIsNewer =
|
||||
lastUpdatedAt &&
|
||||
(!existingTemplate.lastUpdatedAt ||
|
||||
new Date(lastUpdatedAt) > new Date(existingTemplate.lastUpdatedAt));
|
||||
|
||||
groupedTemplates.set(key, {
|
||||
...(incomingIsNewer ? normalizedTemplate : existingTemplate),
|
||||
inboxes,
|
||||
inboxNames: inboxes.map(item => item.name).join(', '),
|
||||
lastUpdatedAt: incomingIsNewer
|
||||
? lastUpdatedAt
|
||||
: existingTemplate.lastUpdatedAt,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
groupedTemplates.set(key, normalizedTemplate);
|
||||
});
|
||||
|
||||
return [...groupedTemplates.values()].sort((first, second) =>
|
||||
first.name.localeCompare(second.name)
|
||||
);
|
||||
};
|
||||
|
||||
export const formatTemplateLabel = value => {
|
||||
if (!value) return '—';
|
||||
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replaceAll('_', ' ')
|
||||
.replace(/\b\w/g, character => character.toUpperCase());
|
||||
};
|
||||
|
||||
export const formatTemplateLanguage = language => {
|
||||
if (!language) return '—';
|
||||
|
||||
const locale = language.replace('_', '-');
|
||||
const languageCode = locale.split('-')[0];
|
||||
const displayName = new Intl.DisplayNames([locale], {
|
||||
type: 'language',
|
||||
}).of(languageCode);
|
||||
|
||||
return `${displayName} (${locale})`;
|
||||
};
|
||||
|
||||
export const formatTemplateDate = value => {
|
||||
if (!value) return '—';
|
||||
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: 'medium',
|
||||
}).format(new Date(value));
|
||||
};
|
||||
|
||||
export const templateStatusClasses = status => {
|
||||
const classes = {
|
||||
approved: 'bg-n-teal-3 text-n-teal-11',
|
||||
pending: 'bg-n-amber-3 text-n-amber-11',
|
||||
rejected: 'bg-n-ruby-3 text-n-ruby-11',
|
||||
paused: 'bg-n-amber-3 text-n-amber-11',
|
||||
disabled: 'bg-n-alpha-2 text-n-slate-11',
|
||||
};
|
||||
|
||||
return classes[status?.toLowerCase()] || 'bg-n-alpha-2 text-n-slate-11';
|
||||
};
|
||||
|
||||
export const templateTypeKey = template => {
|
||||
const type =
|
||||
template.platform === PLATFORMS.TWILIO
|
||||
? TemplateTypeDetector.detectTwilioType(template)
|
||||
: TemplateTypeDetector.detectWhatsAppType(template);
|
||||
return TEMPLATE_TYPE_KEYS[type] || 'TEXT';
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
|
||||
import SettingsWrapper from '../SettingsWrapper.vue';
|
||||
import Index from './Index.vue';
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/templates'),
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'settings_templates',
|
||||
component: Index,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -170,8 +170,10 @@ export const actions = {
|
||||
const response = await InboxesAPI.get(true);
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
|
||||
commit(types.default.SET_INBOXES, response.data.payload);
|
||||
return true;
|
||||
} catch (error) {
|
||||
commit(types.default.SET_INBOXES_UI_FLAG, { isFetching: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
createChannel: async ({ commit }, params) => {
|
||||
|
||||
Reference in New Issue
Block a user