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:
Muhsin Keloth
2026-08-04 15:09:26 +04:00
committed by GitHub
parent 94e9727eb4
commit 544f0da63c
15 changed files with 1021 additions and 1 deletions

View File

@@ -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`,

View File

@@ -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(

View File

@@ -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'),

View File

@@ -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,

View File

@@ -340,6 +340,7 @@
"AGENT_BOTS": "Bots",
"AUDIT_LOGS": "Audit Logs",
"INBOXES": "Inboxes",
"WHATSAPP_TEMPLATES": "Templates",
"NOTIFICATIONS": "Notifications",
"CANNED_RESPONSES": "Canned Responses",
"INTEGRATIONS": "Integrations",

View File

@@ -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"
}
}
}

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"}]',
]);
});
});

View File

@@ -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';
};

View File

@@ -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'],
},
},
],
},
],
};

View File

@@ -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) => {