diff --git a/app/javascript/dashboard/api/inboxes.js b/app/javascript/dashboard/api/inboxes.js index 21fd64f8d..c6945d94f 100644 --- a/app/javascript/dashboard/api/inboxes.js +++ b/app/javascript/dashboard/api/inboxes.js @@ -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`, diff --git a/app/javascript/dashboard/api/specs/inboxes.spec.js b/app/javascript/dashboard/api/specs/inboxes.spec.js index 19aad6b3a..41d200da6 100644 --- a/app/javascript/dashboard/api/specs/inboxes.spec.js +++ b/app/javascript/dashboard/api/specs/inboxes.spec.js @@ -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( diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index 9891b1d86..eda659765 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -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'), diff --git a/app/javascript/dashboard/i18n/locale/en/index.js b/app/javascript/dashboard/i18n/locale/en/index.js index 990ab9835..1b795a9a6 100644 --- a/app/javascript/dashboard/i18n/locale/en/index.js +++ b/app/javascript/dashboard/i18n/locale/en/index.js @@ -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, diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index b133ca590..e36732e48 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -340,6 +340,7 @@ "AGENT_BOTS": "Bots", "AUDIT_LOGS": "Audit Logs", "INBOXES": "Inboxes", + "WHATSAPP_TEMPLATES": "Templates", "NOTIFICATIONS": "Notifications", "CANNED_RESPONSES": "Canned Responses", "INTEGRATIONS": "Integrations", diff --git a/app/javascript/dashboard/i18n/locale/en/whatsappTemplateMgmt.json b/app/javascript/dashboard/i18n/locale/en/whatsappTemplateMgmt.json new file mode 100644 index 000000000..02831d968 --- /dev/null +++ b/app/javascript/dashboard/i18n/locale/en/whatsappTemplateMgmt.json @@ -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" + } + } +} diff --git a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue index 2fa3bf0b8..af8e4d120 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/components/BaseSettingsHeader.vue @@ -119,7 +119,7 @@ const helpURL = getHelpUrlForFeature(props.featureName); >
diff --git a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js index c37093176..b9c0f01da 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js +++ b/app/javascript/dashboard/routes/dashboard/settings/settings.routes.js @@ -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, diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/templates/Index.vue new file mode 100644 index 000000000..fcc033ae3 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/templates/Index.vue @@ -0,0 +1,391 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/TemplateCard.vue b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplateCard.vue new file mode 100644 index 000000000..5cb928df4 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplateCard.vue @@ -0,0 +1,94 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/TemplatePreviewDrawer.vue b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplatePreviewDrawer.vue new file mode 100644 index 000000000..d524994a6 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/templates/TemplatePreviewDrawer.vue @@ -0,0 +1,143 @@ + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/specs/templateUtils.spec.js b/app/javascript/dashboard/routes/dashboard/settings/templates/specs/templateUtils.spec.js new file mode 100644 index 000000000..8ca8d9a6b --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/templates/specs/templateUtils.spec.js @@ -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"}]', + ]); + }); +}); diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/templateUtils.js b/app/javascript/dashboard/routes/dashboard/settings/templates/templateUtils.js new file mode 100644 index 000000000..90808720a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/templates/templateUtils.js @@ -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'; +}; diff --git a/app/javascript/dashboard/routes/dashboard/settings/templates/templates.routes.js b/app/javascript/dashboard/routes/dashboard/settings/templates/templates.routes.js new file mode 100644 index 000000000..505abe53a --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/settings/templates/templates.routes.js @@ -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'], + }, + }, + ], + }, + ], +}; diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index 64a29c215..ed2db91bb 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -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) => {