mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-30 18:47:51 +00:00 
			
		
		
		
	feat: Add twilio content templates (#12277)
Implements comprehensive Twilio WhatsApp content template support (Phase
1) enabling text, media, and quick reply templates with proper parameter
conversion, sync capabilities, and feature flag protection.
###  Features Implemented
  **Template Types Supported**
  - Basic Text Templates: Simple text with variables ({{1}}, {{2}})
  - Media Templates: Image/Video/Document templates with text variables
  - Quick Reply Templates: Interactive button templates
- Phase 2 (Future): List Picker, Call-to-Action, Catalog, Carousel,
Authentication templates
  **Template Synchronization**
- API Endpoint: POST
/api/v1/accounts/{account_id}/inboxes/{inbox_id}/sync_templates
  - Background Job: Channels::Twilio::TemplatesSyncJob
  - Storage: JSONB format in channel_twilio_sms.content_templates
  - Auto-categorization: UTILITY, MARKETING, AUTHENTICATION categories
 ###  Template Examples Tested
  #### Text template
```
  { "name": "greet", "language": "en" }
```
  #### Template with variables
```
  { "name": "order_status", "parameters": [{"type": "body", "parameters": [{"text": "John"}]}] }
```
  #### Media template with image
```
  { "name": "product_showcase", "parameters": [
    {"type": "header", "parameters": [{"image": {"link": "image.jpg"}}]},
    {"type": "body", "parameters": [{"text": "iPhone"}, {"text": "$999"}]}
  ]}
```
#### Preview
<img width="1362" height="1058" alt="CleanShot 2025-08-26 at 10 01
51@2x"
src="https://github.com/user-attachments/assets/cb280cea-08c3-44ca-8025-58a96cb3a451"
/>
<img width="1308" height="1246" alt="CleanShot 2025-08-26 at 10 02
02@2x"
src="https://github.com/user-attachments/assets/9ea8537a-61e9-40f5-844f-eaad337e1ddd"
/>
#### User guide
https://www.chatwoot.com/hc/user-guide/articles/1756195741-twilio-content-templates
---------
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
			
			
This commit is contained in:
		| @@ -11,12 +11,14 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper'; | |||||||
|  |  | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
| import WhatsAppOptions from './WhatsAppOptions.vue'; | import WhatsAppOptions from './WhatsAppOptions.vue'; | ||||||
|  | import ContentTemplateSelector from './ContentTemplateSelector.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
|   attachedFiles: { type: Array, default: () => [] }, |   attachedFiles: { type: Array, default: () => [] }, | ||||||
|   isWhatsappInbox: { type: Boolean, default: false }, |   isWhatsappInbox: { type: Boolean, default: false }, | ||||||
|   isEmailOrWebWidgetInbox: { type: Boolean, default: false }, |   isEmailOrWebWidgetInbox: { type: Boolean, default: false }, | ||||||
|   isTwilioSmsInbox: { type: Boolean, default: false }, |   isTwilioSmsInbox: { type: Boolean, default: false }, | ||||||
|  |   isTwilioWhatsAppInbox: { type: Boolean, default: false }, | ||||||
|   messageTemplates: { type: Array, default: () => [] }, |   messageTemplates: { type: Array, default: () => [] }, | ||||||
|   channelType: { type: String, default: '' }, |   channelType: { type: String, default: '' }, | ||||||
|   isLoading: { type: Boolean, default: false }, |   isLoading: { type: Boolean, default: false }, | ||||||
| @@ -32,6 +34,7 @@ const emit = defineEmits([ | |||||||
|   'discard', |   'discard', | ||||||
|   'sendMessage', |   'sendMessage', | ||||||
|   'sendWhatsappMessage', |   'sendWhatsappMessage', | ||||||
|  |   'sendTwilioMessage', | ||||||
|   'insertEmoji', |   'insertEmoji', | ||||||
|   'addSignature', |   'addSignature', | ||||||
|   'removeSignature', |   'removeSignature', | ||||||
| @@ -63,6 +66,20 @@ const sendWithSignature = computed(() => { | |||||||
|   return fetchSignatureFlagFromUISettings(props.channelType); |   return fetchSignatureFlagFromUISettings(props.channelType); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|  | const showTwilioContentTemplates = computed(() => { | ||||||
|  |   return props.isTwilioWhatsAppInbox && props.inboxId; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const shouldShowEmojiButton = computed(() => { | ||||||
|  |   return ( | ||||||
|  |     !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isRegularMessageMode = computed(() => { | ||||||
|  |   return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox; | ||||||
|  | }); | ||||||
|  |  | ||||||
| const setSignature = () => { | const setSignature = () => { | ||||||
|   if (signatureToApply.value) { |   if (signatureToApply.value) { | ||||||
|     if (sendWithSignature.value) { |     if (sendWithSignature.value) { | ||||||
| @@ -125,7 +142,7 @@ const keyboardEvents = { | |||||||
|     action: () => { |     action: () => { | ||||||
|       if ( |       if ( | ||||||
|         isEditorHotKeyEnabled('enter') && |         isEditorHotKeyEnabled('enter') && | ||||||
|         !props.isWhatsappInbox && |         isRegularMessageMode.value && | ||||||
|         !props.isDropdownActive |         !props.isDropdownActive | ||||||
|       ) { |       ) { | ||||||
|         emit('sendMessage'); |         emit('sendMessage'); | ||||||
| @@ -136,7 +153,7 @@ const keyboardEvents = { | |||||||
|     action: () => { |     action: () => { | ||||||
|       if ( |       if ( | ||||||
|         isEditorHotKeyEnabled('cmd_enter') && |         isEditorHotKeyEnabled('cmd_enter') && | ||||||
|         !props.isWhatsappInbox && |         isRegularMessageMode.value && | ||||||
|         !props.isDropdownActive |         !props.isDropdownActive | ||||||
|       ) { |       ) { | ||||||
|         emit('sendMessage'); |         emit('sendMessage'); | ||||||
| @@ -158,8 +175,13 @@ useKeyboardEvents(keyboardEvents); | |||||||
|         :message-templates="messageTemplates" |         :message-templates="messageTemplates" | ||||||
|         @send-message="emit('sendWhatsappMessage', $event)" |         @send-message="emit('sendWhatsappMessage', $event)" | ||||||
|       /> |       /> | ||||||
|  |       <ContentTemplateSelector | ||||||
|  |         v-if="showTwilioContentTemplates" | ||||||
|  |         :inbox-id="inboxId" | ||||||
|  |         @send-message="emit('sendTwilioMessage', $event)" | ||||||
|  |       /> | ||||||
|       <div |       <div | ||||||
|         v-if="!isWhatsappInbox && !hasNoInbox" |         v-if="shouldShowEmojiButton" | ||||||
|         v-on-click-outside="() => (isEmojiPickerOpen = false)" |         v-on-click-outside="() => (isEmojiPickerOpen = false)" | ||||||
|         class="relative" |         class="relative" | ||||||
|       > |       > | ||||||
| @@ -172,7 +194,7 @@ useKeyboardEvents(keyboardEvents); | |||||||
|         /> |         /> | ||||||
|         <EmojiInput |         <EmojiInput | ||||||
|           v-if="isEmojiPickerOpen" |           v-if="isEmojiPickerOpen" | ||||||
|           class="ltr:left-0 rtl:right-0 top-full mt-1.5" |           class="top-full mt-1.5 ltr:left-0 rtl:right-0" | ||||||
|           :on-click="onClickInsertEmoji" |           :on-click="onClickInsertEmoji" | ||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
| @@ -199,7 +221,7 @@ useKeyboardEvents(keyboardEvents); | |||||||
|         /> |         /> | ||||||
|       </FileUpload> |       </FileUpload> | ||||||
|       <Button |       <Button | ||||||
|         v-if="hasSelectedInbox && !isWhatsappInbox" |         v-if="hasSelectedInbox && isRegularMessageMode" | ||||||
|         icon="i-lucide-signature" |         icon="i-lucide-signature" | ||||||
|         color="slate" |         color="slate" | ||||||
|         size="sm" |         size="sm" | ||||||
| @@ -218,7 +240,7 @@ useKeyboardEvents(keyboardEvents); | |||||||
|         @click="emit('discard')" |         @click="emit('discard')" | ||||||
|       /> |       /> | ||||||
|       <Button |       <Button | ||||||
|         v-if="!isWhatsappInbox" |         v-if="isRegularMessageMode" | ||||||
|         :label="sendButtonLabel" |         :label="sendButtonLabel" | ||||||
|         size="sm" |         size="sm" | ||||||
|         class="!text-xs font-medium" |         class="!text-xs font-medium" | ||||||
|   | |||||||
| @@ -74,6 +74,9 @@ const inboxTypes = computed(() => ({ | |||||||
|   isTwilioSMS: |   isTwilioSMS: | ||||||
|     props.targetInbox?.channelType === INBOX_TYPES.TWILIO && |     props.targetInbox?.channelType === INBOX_TYPES.TWILIO && | ||||||
|     props.targetInbox?.medium === 'sms', |     props.targetInbox?.medium === 'sms', | ||||||
|  |   isTwilioWhatsapp: | ||||||
|  |     props.targetInbox?.channelType === INBOX_TYPES.TWILIO && | ||||||
|  |     props.targetInbox?.medium === 'whatsapp', | ||||||
| })); | })); | ||||||
|  |  | ||||||
| const whatsappMessageTemplates = computed(() => | const whatsappMessageTemplates = computed(() => | ||||||
| @@ -261,6 +264,28 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => { | |||||||
|     isFromWhatsApp: true, |     isFromWhatsApp: true, | ||||||
|   }); |   }); | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const handleSendTwilioMessage = async ({ message, templateParams }) => { | ||||||
|  |   const twilioMessagePayload = prepareWhatsAppMessagePayload({ | ||||||
|  |     targetInbox: props.targetInbox, | ||||||
|  |     selectedContact: props.selectedContact, | ||||||
|  |     message, | ||||||
|  |     templateParams, | ||||||
|  |     currentUser: props.currentUser, | ||||||
|  |   }); | ||||||
|  |   await emit('createConversation', { | ||||||
|  |     payload: twilioMessagePayload, | ||||||
|  |     isFromWhatsApp: true, | ||||||
|  |   }); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const shouldShowMessageEditor = computed(() => { | ||||||
|  |   return ( | ||||||
|  |     !inboxTypes.value.isWhatsapp && | ||||||
|  |     !showNoInboxAlert.value && | ||||||
|  |     !inboxTypes.value.isTwilioWhatsapp | ||||||
|  |   ); | ||||||
|  | }); | ||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
| @@ -311,7 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => { | |||||||
|     /> |     /> | ||||||
|  |  | ||||||
|     <MessageEditor |     <MessageEditor | ||||||
|       v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert" |       v-if="shouldShowMessageEditor" | ||||||
|       v-model="state.message" |       v-model="state.message" | ||||||
|       :message-signature="messageSignature" |       :message-signature="messageSignature" | ||||||
|       :send-with-signature="sendWithSignature" |       :send-with-signature="sendWithSignature" | ||||||
| @@ -331,6 +356,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => { | |||||||
|       :is-whatsapp-inbox="inboxTypes.isWhatsapp" |       :is-whatsapp-inbox="inboxTypes.isWhatsapp" | ||||||
|       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" |       :is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget" | ||||||
|       :is-twilio-sms-inbox="inboxTypes.isTwilioSMS" |       :is-twilio-sms-inbox="inboxTypes.isTwilioSMS" | ||||||
|  |       :is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp" | ||||||
|       :message-templates="whatsappMessageTemplates" |       :message-templates="whatsappMessageTemplates" | ||||||
|       :channel-type="inboxChannelType" |       :channel-type="inboxChannelType" | ||||||
|       :is-loading="isCreating" |       :is-loading="isCreating" | ||||||
| @@ -347,6 +373,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => { | |||||||
|       @discard="$emit('discard')" |       @discard="$emit('discard')" | ||||||
|       @send-message="handleSendMessage" |       @send-message="handleSendMessage" | ||||||
|       @send-whatsapp-message="handleSendWhatsappMessage" |       @send-whatsapp-message="handleSendWhatsappMessage" | ||||||
|  |       @send-twilio-message="handleSendTwilioMessage" | ||||||
|     /> |     /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
|   | |||||||
| @@ -0,0 +1,56 @@ | |||||||
|  | <script setup> | ||||||
|  | import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  |  | ||||||
|  | defineProps({ | ||||||
|  |   template: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['sendMessage', 'back']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const handleSendMessage = payload => { | ||||||
|  |   emit('sendMessage', payload); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBack = () => { | ||||||
|  |   emit('back'); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div | ||||||
|  |     class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg" | ||||||
|  |   > | ||||||
|  |     <div class="w-full"> | ||||||
|  |       <ContentTemplateParser | ||||||
|  |         :template="template" | ||||||
|  |         @send-message="handleSendMessage" | ||||||
|  |         @back="handleBack" | ||||||
|  |       > | ||||||
|  |         <template #actions="{ sendMessage, goBack, disabled }"> | ||||||
|  |           <div class="flex gap-3 justify-between items-end w-full h-14"> | ||||||
|  |             <Button | ||||||
|  |               :label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')" | ||||||
|  |               color="slate" | ||||||
|  |               variant="faded" | ||||||
|  |               class="w-full font-medium" | ||||||
|  |               @click="goBack" | ||||||
|  |             /> | ||||||
|  |             <Button | ||||||
|  |               :label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')" | ||||||
|  |               class="w-full font-medium" | ||||||
|  |               :disabled="disabled" | ||||||
|  |               @click="sendMessage" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |         </template> | ||||||
|  |       </ContentTemplateParser> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,124 @@ | |||||||
|  | <script setup> | ||||||
|  | import { computed, ref } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useMapGetter } from 'dashboard/composables/store'; | ||||||
|  |  | ||||||
|  | import Icon from 'dashboard/components-next/icon/Icon.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  | import ContentTemplateForm from './ContentTemplateForm.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   inboxId: { | ||||||
|  |     type: Number, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['sendMessage']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const inbox = useMapGetter('inboxes/getInbox'); | ||||||
|  |  | ||||||
|  | const searchQuery = ref(''); | ||||||
|  | const selectedTemplate = ref(null); | ||||||
|  | const showTemplatesMenu = ref(false); | ||||||
|  |  | ||||||
|  | const contentTemplates = computed(() => { | ||||||
|  |   const inboxData = inbox.value(props.inboxId); | ||||||
|  |   return inboxData?.content_templates?.templates || []; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const filteredTemplates = computed(() => { | ||||||
|  |   return contentTemplates.value.filter( | ||||||
|  |     template => | ||||||
|  |       template.friendly_name | ||||||
|  |         .toLowerCase() | ||||||
|  |         .includes(searchQuery.value.toLowerCase()) && | ||||||
|  |       template.status === 'approved' | ||||||
|  |   ); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleTriggerClick = () => { | ||||||
|  |   searchQuery.value = ''; | ||||||
|  |   showTemplatesMenu.value = !showTemplatesMenu.value; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleTemplateClick = template => { | ||||||
|  |   selectedTemplate.value = template; | ||||||
|  |   showTemplatesMenu.value = false; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBack = () => { | ||||||
|  |   selectedTemplate.value = null; | ||||||
|  |   showTemplatesMenu.value = true; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleSendMessage = template => { | ||||||
|  |   emit('sendMessage', template); | ||||||
|  |   selectedTemplate.value = null; | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="relative"> | ||||||
|  |     <Button | ||||||
|  |       icon="i-ph-whatsapp-logo" | ||||||
|  |       :label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')" | ||||||
|  |       color="slate" | ||||||
|  |       size="sm" | ||||||
|  |       :disabled="selectedTemplate" | ||||||
|  |       class="!text-xs font-medium" | ||||||
|  |       @click="handleTriggerClick" | ||||||
|  |     /> | ||||||
|  |     <div | ||||||
|  |       v-if="showTemplatesMenu" | ||||||
|  |       class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-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="w-full"> | ||||||
|  |         <Input | ||||||
|  |           v-model="searchQuery" | ||||||
|  |           type="search" | ||||||
|  |           :placeholder=" | ||||||
|  |             t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER') | ||||||
|  |           " | ||||||
|  |           custom-input-class="ltr:pl-10 rtl:pr-10" | ||||||
|  |         > | ||||||
|  |           <template #prefix> | ||||||
|  |             <Icon | ||||||
|  |               icon="i-lucide-search" | ||||||
|  |               class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3" | ||||||
|  |             /> | ||||||
|  |           </template> | ||||||
|  |         </Input> | ||||||
|  |       </div> | ||||||
|  |       <div | ||||||
|  |         v-for="template in filteredTemplates" | ||||||
|  |         :key="template.content_sid" | ||||||
|  |         tabindex="0" | ||||||
|  |         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)" | ||||||
|  |       > | ||||||
|  |         <div class="flex justify-between items-center"> | ||||||
|  |           <span class="text-sm text-n-slate-12">{{ | ||||||
|  |             template.friendly_name | ||||||
|  |           }}</span> | ||||||
|  |         </div> | ||||||
|  |         <p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2"> | ||||||
|  |           {{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }} | ||||||
|  |         </p> | ||||||
|  |       </div> | ||||||
|  |       <template v-if="filteredTemplates.length === 0"> | ||||||
|  |         <p class="pt-2 w-full text-sm text-n-slate-11"> | ||||||
|  |           {{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }} | ||||||
|  |         </p> | ||||||
|  |       </template> | ||||||
|  |     </div> | ||||||
|  |     <ContentTemplateForm | ||||||
|  |       v-if="selectedTemplate" | ||||||
|  |       :template="selectedTemplate" | ||||||
|  |       @send-message="handleSendMessage" | ||||||
|  |       @back="handleBack" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({ | |||||||
|     channelType === INBOX_TYPES.TWILIO || |     channelType === INBOX_TYPES.TWILIO || | ||||||
|     channelType === INBOX_TYPES.WHATSAPP |     channelType === INBOX_TYPES.WHATSAPP | ||||||
|   ) { |   ) { | ||||||
|     return `${name} (${phoneNumber})`; |     return phoneNumber ? `${name} (${phoneNumber})` : name; | ||||||
|   } |   } | ||||||
|   return name; |   return name; | ||||||
| }; | }; | ||||||
| @@ -53,6 +53,7 @@ const transformInbox = ({ | |||||||
|   email, |   email, | ||||||
|   phoneNumber, |   phoneNumber, | ||||||
|   channelType, |   channelType, | ||||||
|  |   medium, | ||||||
|   ...rest, |   ...rest, | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -0,0 +1,278 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed, onMounted, watch } from 'vue'; | ||||||
|  | import { useVuelidate } from '@vuelidate/core'; | ||||||
|  | import { requiredIf } from '@vuelidate/validators'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper'; | ||||||
|  | import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages'; | ||||||
|  |  | ||||||
|  | import Input from 'dashboard/components-next/input/Input.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   template: { | ||||||
|  |     type: Object, | ||||||
|  |     default: () => ({}), | ||||||
|  |     validator: value => { | ||||||
|  |       if (!value || typeof value !== 'object') return false; | ||||||
|  |       if (!value.friendly_name) return false; | ||||||
|  |       return true; | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']); | ||||||
|  |  | ||||||
|  | const VARIABLE_PATTERN = /{{([^}]+)}}/g; | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const processedParams = ref({}); | ||||||
|  |  | ||||||
|  | const languageLabel = computed(() => { | ||||||
|  |   return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const categoryLabel = computed(() => { | ||||||
|  |   return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const templateBody = computed(() => { | ||||||
|  |   return props.template.body || ''; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasMediaTemplate = computed(() => { | ||||||
|  |   return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasVariables = computed(() => { | ||||||
|  |   return templateBody.value?.match(VARIABLE_PATTERN) !== null; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const mediaVariableKey = computed(() => { | ||||||
|  |   if (!hasMediaTemplate.value) return null; | ||||||
|  |   const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0]; | ||||||
|  |   if (!mediaUrl) return null; | ||||||
|  |   return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const hasMediaVariable = computed(() => { | ||||||
|  |   return hasMediaTemplate.value && mediaVariableKey.value !== null; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const templateMediaUrl = computed(() => { | ||||||
|  |   if (!hasMediaTemplate.value) return ''; | ||||||
|  |  | ||||||
|  |   return props.template?.types?.['twilio/media']?.media?.[0] || ''; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const variablePattern = computed(() => { | ||||||
|  |   if (!hasVariables.value) return []; | ||||||
|  |   const matches = templateBody.value.match(VARIABLE_PATTERN) || []; | ||||||
|  |   return matches.map(match => match.replace(/[{}]/g, '')); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const renderedTemplate = computed(() => { | ||||||
|  |   let rendered = templateBody.value; | ||||||
|  |   if (processedParams.value && Object.keys(processedParams.value).length > 0) { | ||||||
|  |     // Replace variables in the format {{1}}, {{2}}, etc. | ||||||
|  |     rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => { | ||||||
|  |       const cleanVariable = variable.trim(); | ||||||
|  |       return processedParams.value[cleanVariable] || match; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   return rendered; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const isFormInvalid = computed(() => { | ||||||
|  |   if (!hasVariables.value && !hasMediaVariable.value) return false; | ||||||
|  |  | ||||||
|  |   if (hasVariables.value) { | ||||||
|  |     const hasEmptyVariable = variablePattern.value.some( | ||||||
|  |       variable => !processedParams.value[variable] | ||||||
|  |     ); | ||||||
|  |     if (hasEmptyVariable) return true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if ( | ||||||
|  |     hasMediaVariable.value && | ||||||
|  |     mediaVariableKey.value && | ||||||
|  |     !processedParams.value[mediaVariableKey.value] | ||||||
|  |   ) { | ||||||
|  |     return true; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   return false; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const v$ = useVuelidate( | ||||||
|  |   { | ||||||
|  |     processedParams: { | ||||||
|  |       requiredIfKeysPresent: requiredIf( | ||||||
|  |         () => hasVariables.value || hasMediaVariable.value | ||||||
|  |       ), | ||||||
|  |     }, | ||||||
|  |   }, | ||||||
|  |   { processedParams } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const initializeTemplateParameters = () => { | ||||||
|  |   processedParams.value = {}; | ||||||
|  |  | ||||||
|  |   if (hasVariables.value) { | ||||||
|  |     variablePattern.value.forEach(variable => { | ||||||
|  |       processedParams.value[variable] = ''; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   if (hasMediaVariable.value && mediaVariableKey.value) { | ||||||
|  |     processedParams.value[mediaVariableKey.value] = ''; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const sendMessage = () => { | ||||||
|  |   v$.value.$touch(); | ||||||
|  |   if (v$.value.$invalid || isFormInvalid.value) return; | ||||||
|  |  | ||||||
|  |   const { friendly_name, language } = props.template; | ||||||
|  |  | ||||||
|  |   // Process parameters and extract filename from media URL if needed | ||||||
|  |   const processedParameters = { ...processedParams.value }; | ||||||
|  |  | ||||||
|  |   // For media templates, extract filename from full URL | ||||||
|  |   if ( | ||||||
|  |     hasMediaVariable.value && | ||||||
|  |     mediaVariableKey.value && | ||||||
|  |     processedParameters[mediaVariableKey.value] | ||||||
|  |   ) { | ||||||
|  |     processedParameters[mediaVariableKey.value] = extractFilenameFromUrl( | ||||||
|  |       processedParameters[mediaVariableKey.value] | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const payload = { | ||||||
|  |     message: renderedTemplate.value, | ||||||
|  |     templateParams: { | ||||||
|  |       name: friendly_name, | ||||||
|  |       language, | ||||||
|  |       processed_params: processedParameters, | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  |   emit('sendMessage', payload); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const resetTemplate = () => { | ||||||
|  |   emit('resetTemplate'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const goBack = () => { | ||||||
|  |   emit('back'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | onMounted(initializeTemplateParameters); | ||||||
|  |  | ||||||
|  | watch( | ||||||
|  |   () => props.template, | ||||||
|  |   () => { | ||||||
|  |     initializeTemplateParameters(); | ||||||
|  |     v$.value.$reset(); | ||||||
|  |   }, | ||||||
|  |   { deep: true } | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | defineExpose({ | ||||||
|  |   processedParams, | ||||||
|  |   hasVariables, | ||||||
|  |   hasMediaTemplate, | ||||||
|  |   renderedTemplate, | ||||||
|  |   v$, | ||||||
|  |   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.friendly_name }} | ||||||
|  |         </h3> | ||||||
|  |         <span class="text-xs text-n-slate-11"> | ||||||
|  |           {{ languageLabel }} | ||||||
|  |         </span> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="flex flex-col gap-2"> | ||||||
|  |         <div class="rounded-md"> | ||||||
|  |           <div class="text-sm whitespace-pre-wrap text-n-slate-12"> | ||||||
|  |             {{ renderedTemplate }} | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <div class="text-xs text-n-slate-11"> | ||||||
|  |         {{ categoryLabel }} | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <div v-if="hasVariables || hasMediaVariable"> | ||||||
|  |       <!-- Media URL for media templates --> | ||||||
|  |       <div v-if="hasMediaVariable" class="mb-4"> | ||||||
|  |         <p class="mb-2.5 text-sm font-semibold"> | ||||||
|  |           {{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }} | ||||||
|  |         </p> | ||||||
|  |         <div class="flex items-center mb-2.5"> | ||||||
|  |           <Input | ||||||
|  |             v-model="processedParams[mediaVariableKey]" | ||||||
|  |             type="url" | ||||||
|  |             class="flex-1" | ||||||
|  |             :placeholder=" | ||||||
|  |               templateMediaUrl || | ||||||
|  |               t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER') | ||||||
|  |             " | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <!-- Body Variables Section --> | ||||||
|  |       <div v-if="hasVariables"> | ||||||
|  |         <p class="mb-2.5 text-sm font-semibold"> | ||||||
|  |           {{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }} | ||||||
|  |         </p> | ||||||
|  |         <div | ||||||
|  |           v-for="variable in variablePattern" | ||||||
|  |           :key="`variable-${variable}`" | ||||||
|  |           class="flex items-center mb-2.5" | ||||||
|  |         > | ||||||
|  |           <Input | ||||||
|  |             v-model="processedParams[variable]" | ||||||
|  |             type="text" | ||||||
|  |             class="flex-1" | ||||||
|  |             :placeholder=" | ||||||
|  |               t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', { | ||||||
|  |                 variable: variable, | ||||||
|  |               }) | ||||||
|  |             " | ||||||
|  |           /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |  | ||||||
|  |       <p | ||||||
|  |         v-if="v$.$dirty && (v$.$invalid || isFormInvalid)" | ||||||
|  |         class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9" | ||||||
|  |       > | ||||||
|  |         {{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }} | ||||||
|  |       </p> | ||||||
|  |     </div> | ||||||
|  |  | ||||||
|  |     <slot | ||||||
|  |       name="actions" | ||||||
|  |       :send-message="sendMessage" | ||||||
|  |       :reset-template="resetTemplate" | ||||||
|  |       :go-back="goBack" | ||||||
|  |       :is-valid="!v$.$invalid && !isFormInvalid" | ||||||
|  |       :disabled="isFormInvalid" | ||||||
|  |     /> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -95,6 +95,10 @@ export default { | |||||||
|       type: Boolean, |       type: Boolean, | ||||||
|       default: false, |       default: false, | ||||||
|     }, |     }, | ||||||
|  |     enableContentTemplates: { | ||||||
|  |       type: Boolean, | ||||||
|  |       default: false, | ||||||
|  |     }, | ||||||
|     conversationId: { |     conversationId: { | ||||||
|       type: Number, |       type: Number, | ||||||
|       required: true, |       required: true, | ||||||
| @@ -121,6 +125,7 @@ export default { | |||||||
|     'toggleInsertArticle', |     'toggleInsertArticle', | ||||||
|     'toggleEditor', |     'toggleEditor', | ||||||
|     'selectWhatsappTemplate', |     'selectWhatsappTemplate', | ||||||
|  |     'selectContentTemplate', | ||||||
|   ], |   ], | ||||||
|   setup() { |   setup() { | ||||||
|     const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } = |     const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } = | ||||||
| @@ -347,6 +352,15 @@ export default { | |||||||
|         sm |         sm | ||||||
|         @click="$emit('selectWhatsappTemplate')" |         @click="$emit('selectWhatsappTemplate')" | ||||||
|       /> |       /> | ||||||
|  |       <NextButton | ||||||
|  |         v-if="enableContentTemplates" | ||||||
|  |         v-tooltip.top-end="'Content Templates'" | ||||||
|  |         icon="i-ph-whatsapp-logo" | ||||||
|  |         slate | ||||||
|  |         faded | ||||||
|  |         sm | ||||||
|  |         @click="$emit('selectContentTemplate')" | ||||||
|  |       /> | ||||||
|       <VideoCallButton |       <VideoCallButton | ||||||
|         v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote" |         v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote" | ||||||
|         :conversation-id="conversationId" |         :conversation-id="conversationId" | ||||||
| @@ -361,7 +375,7 @@ export default { | |||||||
|       <transition name="modal-fade"> |       <transition name="modal-fade"> | ||||||
|         <div |         <div | ||||||
|           v-show="uploadRef && uploadRef.dropActive" |           v-show="uploadRef && uploadRef.dropActive" | ||||||
|           class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark" |           class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark" | ||||||
|         > |         > | ||||||
|           <fluent-icon icon="cloud-backup" size="40" /> |           <fluent-icon icon="cloud-backup" size="40" /> | ||||||
|           <h4 class="text-2xl break-words text-n-slate-12"> |           <h4 class="text-2xl break-words text-n-slate-12"> | ||||||
|   | |||||||
| @@ -0,0 +1,97 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import TemplatesPicker from './ContentTemplatesPicker.vue'; | ||||||
|  | import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue'; | ||||||
|  | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   show: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   inboxId: { | ||||||
|  |     type: Number, | ||||||
|  |     default: undefined, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['onSend', 'cancel', 'update:show']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  |  | ||||||
|  | const selectedContentTemplate = ref(null); | ||||||
|  |  | ||||||
|  | const localShow = computed({ | ||||||
|  |   get() { | ||||||
|  |     return props.show; | ||||||
|  |   }, | ||||||
|  |   set(value) { | ||||||
|  |     emit('update:show', value); | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const modalHeaderContent = computed(() => { | ||||||
|  |   return selectedContentTemplate.value | ||||||
|  |     ? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', { | ||||||
|  |         templateName: selectedContentTemplate.value.friendly_name, | ||||||
|  |       }) | ||||||
|  |     : t('CONTENT_TEMPLATES.MODAL.SUBTITLE'); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const pickTemplate = template => { | ||||||
|  |   selectedContentTemplate.value = template; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onResetTemplate = () => { | ||||||
|  |   selectedContentTemplate.value = null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onSendMessage = message => { | ||||||
|  |   emit('onSend', message); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onClose = () => { | ||||||
|  |   emit('cancel'); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big"> | ||||||
|  |     <woot-modal-header | ||||||
|  |       :header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')" | ||||||
|  |       :header-content="modalHeaderContent" | ||||||
|  |     /> | ||||||
|  |     <div class="px-8 py-6 row"> | ||||||
|  |       <TemplatesPicker | ||||||
|  |         v-if="!selectedContentTemplate" | ||||||
|  |         :inbox-id="inboxId" | ||||||
|  |         @on-select="pickTemplate" | ||||||
|  |       /> | ||||||
|  |       <TemplateParser | ||||||
|  |         v-else | ||||||
|  |         :template="selectedContentTemplate" | ||||||
|  |         @reset-template="onResetTemplate" | ||||||
|  |         @send-message="onSendMessage" | ||||||
|  |       > | ||||||
|  |         <template #actions="{ sendMessage, resetTemplate, disabled }"> | ||||||
|  |           <div class="flex gap-2 mt-6"> | ||||||
|  |             <Button | ||||||
|  |               :label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')" | ||||||
|  |               color="slate" | ||||||
|  |               variant="faded" | ||||||
|  |               class="flex-1" | ||||||
|  |               @click="resetTemplate" | ||||||
|  |             /> | ||||||
|  |             <Button | ||||||
|  |               :label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')" | ||||||
|  |               class="flex-1" | ||||||
|  |               :disabled="disabled" | ||||||
|  |               @click="sendMessage" | ||||||
|  |             /> | ||||||
|  |           </div> | ||||||
|  |         </template> | ||||||
|  |       </TemplateParser> | ||||||
|  |     </div> | ||||||
|  |   </woot-modal> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,169 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import { useStore } from 'dashboard/composables/store'; | ||||||
|  | import Icon from 'dashboard/components-next/icon/Icon.vue'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   inboxId: { | ||||||
|  |     type: Number, | ||||||
|  |     default: undefined, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['onSelect']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const query = ref(''); | ||||||
|  | const isRefreshing = ref(false); | ||||||
|  |  | ||||||
|  | const twilioTemplates = computed(() => { | ||||||
|  |   const inbox = store.getters['inboxes/getInbox'](props.inboxId); | ||||||
|  |   return inbox?.content_templates?.templates || []; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const filteredTemplateMessages = computed(() => | ||||||
|  |   twilioTemplates.value.filter( | ||||||
|  |     template => | ||||||
|  |       template.friendly_name | ||||||
|  |         .toLowerCase() | ||||||
|  |         .includes(query.value.toLowerCase()) && template.status === 'approved' | ||||||
|  |   ) | ||||||
|  | ); | ||||||
|  |  | ||||||
|  | const getTemplateType = template => { | ||||||
|  |   if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) { | ||||||
|  |     return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA'); | ||||||
|  |   } | ||||||
|  |   if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) { | ||||||
|  |     return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY'); | ||||||
|  |   } | ||||||
|  |   return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT'); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const refreshTemplates = async () => { | ||||||
|  |   isRefreshing.value = true; | ||||||
|  |   try { | ||||||
|  |     await store.dispatch('inboxes/syncTemplates', props.inboxId); | ||||||
|  |     useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS')); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR')); | ||||||
|  |   } finally { | ||||||
|  |     isRefreshing.value = false; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="w-full"> | ||||||
|  |     <div class="flex gap-2 mb-2.5"> | ||||||
|  |       <div | ||||||
|  |         class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand" | ||||||
|  |       > | ||||||
|  |         <fluent-icon icon="search" class="text-n-slate-12" size="16" /> | ||||||
|  |         <input | ||||||
|  |           v-model="query" | ||||||
|  |           type="search" | ||||||
|  |           :placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')" | ||||||
|  |           class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <button | ||||||
|  |         :disabled="isRefreshing" | ||||||
|  |         class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed" | ||||||
|  |         :title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')" | ||||||
|  |         @click="refreshTemplates" | ||||||
|  |       > | ||||||
|  |         <Icon | ||||||
|  |           icon="i-lucide-refresh-ccw" | ||||||
|  |           class="text-n-slate-12 size-4" | ||||||
|  |           :class="{ 'animate-spin': isRefreshing }" | ||||||
|  |         /> | ||||||
|  |       </button> | ||||||
|  |     </div> | ||||||
|  |     <div | ||||||
|  |       class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5" | ||||||
|  |     > | ||||||
|  |       <div | ||||||
|  |         v-for="(template, i) in filteredTemplateMessages" | ||||||
|  |         :key="template.content_sid" | ||||||
|  |       > | ||||||
|  |         <button | ||||||
|  |           class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2" | ||||||
|  |           @click="emit('onSelect', template)" | ||||||
|  |         > | ||||||
|  |           <div> | ||||||
|  |             <div class="flex justify-between items-center mb-2.5"> | ||||||
|  |               <p class="text-sm"> | ||||||
|  |                 {{ template.friendly_name }} | ||||||
|  |               </p> | ||||||
|  |               <div class="flex gap-2"> | ||||||
|  |                 <span | ||||||
|  |                   class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12" | ||||||
|  |                 > | ||||||
|  |                   {{ getTemplateType(template) }} | ||||||
|  |                 </span> | ||||||
|  |                 <span | ||||||
|  |                   class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12" | ||||||
|  |                 > | ||||||
|  |                   {{ | ||||||
|  |                     `${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}` | ||||||
|  |                   }} | ||||||
|  |                 </span> | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <!-- Body --> | ||||||
|  |             <div> | ||||||
|  |               <p class="text-xs font-medium text-n-slate-11"> | ||||||
|  |                 {{ t('CONTENT_TEMPLATES.PICKER.BODY') }} | ||||||
|  |               </p> | ||||||
|  |               <p class="text-sm label-body"> | ||||||
|  |                 {{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }} | ||||||
|  |               </p> | ||||||
|  |             </div> | ||||||
|  |  | ||||||
|  |             <div class="flex justify-between items-center mt-3"> | ||||||
|  |               <div> | ||||||
|  |                 <p class="text-xs font-medium text-n-slate-11"> | ||||||
|  |                   {{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }} | ||||||
|  |                 </p> | ||||||
|  |                 <p class="text-sm">{{ template.category || 'utility' }}</p> | ||||||
|  |               </div> | ||||||
|  |               <div class="text-xs text-n-slate-11"> | ||||||
|  |                 {{ new Date(template.created_at).toLocaleDateString() }} | ||||||
|  |               </div> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </button> | ||||||
|  |         <hr | ||||||
|  |           v-if="i != filteredTemplateMessages.length - 1" | ||||||
|  |           :key="`hr-${i}`" | ||||||
|  |           class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]" | ||||||
|  |         /> | ||||||
|  |       </div> | ||||||
|  |       <div v-if="!filteredTemplateMessages.length" class="py-8 text-center"> | ||||||
|  |         <div v-if="query && twilioTemplates.length"> | ||||||
|  |           <p> | ||||||
|  |             {{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }} | ||||||
|  |             <strong>{{ query }}</strong> | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |         <div v-else-if="!twilioTemplates.length" class="space-y-4"> | ||||||
|  |           <p class="text-n-slate-11"> | ||||||
|  |             {{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }} | ||||||
|  |           </p> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </div> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
|  |  | ||||||
|  | <style scoped lang="scss"> | ||||||
|  | .label-body { | ||||||
|  |   font-family: monospace; | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @@ -27,6 +27,7 @@ import { | |||||||
|   replaceVariablesInMessage, |   replaceVariablesInMessage, | ||||||
| } from '@chatwoot/utils'; | } from '@chatwoot/utils'; | ||||||
| import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; | import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; | ||||||
|  | import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue'; | ||||||
| import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; | import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper'; | ||||||
| import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; | import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin'; | ||||||
| import { trimContent, debounce, getRecipients } from '@chatwoot/utils'; | import { trimContent, debounce, getRecipients } from '@chatwoot/utils'; | ||||||
| @@ -61,6 +62,7 @@ export default { | |||||||
|     ReplyToMessage, |     ReplyToMessage, | ||||||
|     ReplyTopPanel, |     ReplyTopPanel, | ||||||
|     ResizableTextArea, |     ResizableTextArea, | ||||||
|  |     ContentTemplates, | ||||||
|     WhatsappTemplates, |     WhatsappTemplates, | ||||||
|     WootMessageEditor, |     WootMessageEditor, | ||||||
|   }, |   }, | ||||||
| @@ -109,6 +111,7 @@ export default { | |||||||
|       toEmails: '', |       toEmails: '', | ||||||
|       doAutoSaveDraft: () => {}, |       doAutoSaveDraft: () => {}, | ||||||
|       showWhatsAppTemplatesModal: false, |       showWhatsAppTemplatesModal: false, | ||||||
|  |       showContentTemplatesModal: false, | ||||||
|       updateEditorSelectionWith: '', |       updateEditorSelectionWith: '', | ||||||
|       undefinedVariableMessage: '', |       undefinedVariableMessage: '', | ||||||
|       showMentions: false, |       showMentions: false, | ||||||
| @@ -187,6 +190,9 @@ export default { | |||||||
|     showWhatsappTemplates() { |     showWhatsappTemplates() { | ||||||
|       return this.isAWhatsAppCloudChannel && !this.isPrivate; |       return this.isAWhatsAppCloudChannel && !this.isPrivate; | ||||||
|     }, |     }, | ||||||
|  |     showContentTemplates() { | ||||||
|  |       return this.isATwilioWhatsAppChannel && !this.isPrivate; | ||||||
|  |     }, | ||||||
|     isPrivate() { |     isPrivate() { | ||||||
|       if (this.currentChat.can_reply || this.isAWhatsAppChannel) { |       if (this.currentChat.can_reply || this.isAWhatsAppChannel) { | ||||||
|         return this.isOnPrivateNote; |         return this.isOnPrivateNote; | ||||||
| @@ -659,6 +665,12 @@ export default { | |||||||
|     hideWhatsappTemplatesModal() { |     hideWhatsappTemplatesModal() { | ||||||
|       this.showWhatsAppTemplatesModal = false; |       this.showWhatsAppTemplatesModal = false; | ||||||
|     }, |     }, | ||||||
|  |     openContentTemplateModal() { | ||||||
|  |       this.showContentTemplatesModal = true; | ||||||
|  |     }, | ||||||
|  |     hideContentTemplatesModal() { | ||||||
|  |       this.showContentTemplatesModal = false; | ||||||
|  |     }, | ||||||
|     onClickSelfAssign() { |     onClickSelfAssign() { | ||||||
|       const { |       const { | ||||||
|         account_id, |         account_id, | ||||||
| @@ -774,6 +786,13 @@ export default { | |||||||
|       }); |       }); | ||||||
|       this.hideWhatsappTemplatesModal(); |       this.hideWhatsappTemplatesModal(); | ||||||
|     }, |     }, | ||||||
|  |     async onSendContentTemplateReply(messagePayload) { | ||||||
|  |       this.sendMessage({ | ||||||
|  |         conversationId: this.currentChat.id, | ||||||
|  |         ...messagePayload, | ||||||
|  |       }); | ||||||
|  |       this.hideContentTemplatesModal(); | ||||||
|  |     }, | ||||||
|     replaceText(message) { |     replaceText(message) { | ||||||
|       if (this.sendWithSignature && !this.private) { |       if (this.sendWithSignature && !this.private) { | ||||||
|         // if signature is enabled, append it to the message |         // if signature is enabled, append it to the message | ||||||
| @@ -1217,6 +1236,7 @@ export default { | |||||||
|       :conversation-id="conversationId" |       :conversation-id="conversationId" | ||||||
|       :enable-multiple-file-upload="enableMultipleFileUpload" |       :enable-multiple-file-upload="enableMultipleFileUpload" | ||||||
|       :enable-whats-app-templates="showWhatsappTemplates" |       :enable-whats-app-templates="showWhatsappTemplates" | ||||||
|  |       :enable-content-templates="showContentTemplates" | ||||||
|       :inbox="inbox" |       :inbox="inbox" | ||||||
|       :is-on-private-note="isOnPrivateNote" |       :is-on-private-note="isOnPrivateNote" | ||||||
|       :is-recording-audio="isRecordingAudio" |       :is-recording-audio="isRecordingAudio" | ||||||
| @@ -1239,6 +1259,7 @@ export default { | |||||||
|       :portal-slug="connectedPortalSlug" |       :portal-slug="connectedPortalSlug" | ||||||
|       :new-conversation-modal-active="newConversationModalActive" |       :new-conversation-modal-active="newConversationModalActive" | ||||||
|       @select-whatsapp-template="openWhatsappTemplateModal" |       @select-whatsapp-template="openWhatsappTemplateModal" | ||||||
|  |       @select-content-template="openContentTemplateModal" | ||||||
|       @toggle-editor="toggleRichContentEditor" |       @toggle-editor="toggleRichContentEditor" | ||||||
|       @replace-text="replaceText" |       @replace-text="replaceText" | ||||||
|       @toggle-insert-article="toggleInsertArticle" |       @toggle-insert-article="toggleInsertArticle" | ||||||
| @@ -1251,6 +1272,14 @@ export default { | |||||||
|       @cancel="hideWhatsappTemplatesModal" |       @cancel="hideWhatsappTemplatesModal" | ||||||
|     /> |     /> | ||||||
|  |  | ||||||
|  |     <ContentTemplates | ||||||
|  |       :inbox-id="inbox.id" | ||||||
|  |       :show="showContentTemplatesModal" | ||||||
|  |       @close="hideContentTemplatesModal" | ||||||
|  |       @on-send="onSendContentTemplateReply" | ||||||
|  |       @cancel="hideContentTemplatesModal" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|     <woot-confirm-modal |     <woot-confirm-modal | ||||||
|       ref="confirmDialog" |       ref="confirmDialog" | ||||||
|       :title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')" |       :title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')" | ||||||
|   | |||||||
| @@ -125,3 +125,23 @@ export const getHostNameFromURL = url => { | |||||||
|     return null; |     return null; | ||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Extracts filename from a URL | ||||||
|  |  * @param {string} url - The URL to extract filename from | ||||||
|  |  * @returns {string} - The extracted filename or original URL if extraction fails | ||||||
|  |  */ | ||||||
|  | export const extractFilenameFromUrl = url => { | ||||||
|  |   if (!url || typeof url !== 'string') return url; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     const urlObj = new URL(url); | ||||||
|  |     const pathname = urlObj.pathname; | ||||||
|  |     const filename = pathname.split('/').pop(); | ||||||
|  |     return filename || url; | ||||||
|  |   } catch (error) { | ||||||
|  |     // If URL parsing fails, try to extract filename using regex | ||||||
|  |     const match = url.match(/\/([^/?#]+)(?:[?#]|$)/); | ||||||
|  |     return match ? match[1] : url; | ||||||
|  |   } | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import { | |||||||
|   hasValidAvatarUrl, |   hasValidAvatarUrl, | ||||||
|   timeStampAppendedURL, |   timeStampAppendedURL, | ||||||
|   getHostNameFromURL, |   getHostNameFromURL, | ||||||
|  |   extractFilenameFromUrl, | ||||||
| } from '../URLHelper'; | } from '../URLHelper'; | ||||||
|  |  | ||||||
| describe('#URL Helpers', () => { | describe('#URL Helpers', () => { | ||||||
| @@ -263,4 +264,58 @@ describe('#URL Helpers', () => { | |||||||
|       expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help'); |       expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help'); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|  |   describe('extractFilenameFromUrl', () => { | ||||||
|  |     it('should extract filename from a valid URL', () => { | ||||||
|  |       expect( | ||||||
|  |         extractFilenameFromUrl('https://example.com/path/to/file.jpg') | ||||||
|  |       ).toBe('file.jpg'); | ||||||
|  |       expect(extractFilenameFromUrl('https://example.com/image.png')).toBe( | ||||||
|  |         'image.png' | ||||||
|  |       ); | ||||||
|  |       expect( | ||||||
|  |         extractFilenameFromUrl( | ||||||
|  |           'https://example.com/folder/document.pdf?query=1' | ||||||
|  |         ) | ||||||
|  |       ).toBe('document.pdf'); | ||||||
|  |       expect( | ||||||
|  |         extractFilenameFromUrl('https://example.com/file.txt#section') | ||||||
|  |       ).toBe('file.txt'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should handle URLs without filename', () => { | ||||||
|  |       expect(extractFilenameFromUrl('https://example.com/')).toBe( | ||||||
|  |         'https://example.com/' | ||||||
|  |       ); | ||||||
|  |       expect(extractFilenameFromUrl('https://example.com')).toBe( | ||||||
|  |         'https://example.com' | ||||||
|  |       ); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should handle invalid URLs gracefully', () => { | ||||||
|  |       expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt'); | ||||||
|  |       expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url'); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should handle edge cases', () => { | ||||||
|  |       expect(extractFilenameFromUrl('')).toBe(''); | ||||||
|  |       expect(extractFilenameFromUrl(null)).toBe(null); | ||||||
|  |       expect(extractFilenameFromUrl(undefined)).toBe(undefined); | ||||||
|  |       expect(extractFilenameFromUrl(123)).toBe(123); | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     it('should handle URLs with query parameters and fragments', () => { | ||||||
|  |       expect( | ||||||
|  |         extractFilenameFromUrl( | ||||||
|  |           'https://example.com/file.jpg?size=large&format=png' | ||||||
|  |         ) | ||||||
|  |       ).toBe('file.jpg'); | ||||||
|  |       expect( | ||||||
|  |         extractFilenameFromUrl('https://example.com/file.pdf#page=1') | ||||||
|  |       ).toBe('file.pdf'); | ||||||
|  |       expect( | ||||||
|  |         extractFilenameFromUrl('https://example.com/file.doc?v=1#section') | ||||||
|  |       ).toBe('file.doc'); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -612,6 +612,15 @@ | |||||||
|           "SEND_MESSAGE": "Send message" |           "SEND_MESSAGE": "Send message" | ||||||
|         } |         } | ||||||
|       }, |       }, | ||||||
|  |       "TWILIO_OPTIONS": { | ||||||
|  |         "LABEL": "Select template", | ||||||
|  |         "SEARCH_PLACEHOLDER": "Search templates", | ||||||
|  |         "EMPTY_STATE": "No templates found", | ||||||
|  |         "TEMPLATE_PARSER": { | ||||||
|  |           "BACK": "Go back", | ||||||
|  |           "SEND_MESSAGE": "Send message" | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|       "ACTION_BUTTONS": { |       "ACTION_BUTTONS": { | ||||||
|         "DISCARD": "Discard", |         "DISCARD": "Discard", | ||||||
|         "SEND": "Send ({keyCode})" |         "SEND": "Send ({keyCode})" | ||||||
|   | |||||||
| @@ -0,0 +1,51 @@ | |||||||
|  | { | ||||||
|  |   "CONTENT_TEMPLATES": { | ||||||
|  |     "MODAL": { | ||||||
|  |       "TITLE": "Twilio Templates", | ||||||
|  |       "SUBTITLE": "Select the Twilio template you want to send", | ||||||
|  |       "TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}" | ||||||
|  |     }, | ||||||
|  |     "PICKER": { | ||||||
|  |       "SEARCH_PLACEHOLDER": "Search Templates", | ||||||
|  |       "NO_TEMPLATES_FOUND": "No templates found for", | ||||||
|  |       "NO_CONTENT": "No content", | ||||||
|  |       "HEADER": "Header", | ||||||
|  |       "BODY": "Body", | ||||||
|  |       "FOOTER": "Footer", | ||||||
|  |       "BUTTONS": "Buttons", | ||||||
|  |       "CATEGORY": "Category", | ||||||
|  |       "MEDIA_CONTENT": "Media Content", | ||||||
|  |       "MEDIA_CONTENT_FALLBACK": "media content", | ||||||
|  |       "NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.", | ||||||
|  |       "REFRESH_BUTTON": "Refresh templates", | ||||||
|  |       "REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.", | ||||||
|  |       "REFRESH_ERROR": "Failed to refresh templates. Please try again.", | ||||||
|  |       "LABELS": { | ||||||
|  |         "LANGUAGE": "Language", | ||||||
|  |         "TEMPLATE_BODY": "Template Body", | ||||||
|  |         "CATEGORY": "Category" | ||||||
|  |       }, | ||||||
|  |       "TYPES": { | ||||||
|  |         "MEDIA": "Media", | ||||||
|  |         "QUICK_REPLY": "Quick Reply", | ||||||
|  |         "TEXT": "Text" | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |     "PARSER": { | ||||||
|  |       "VARIABLES_LABEL": "Variables", | ||||||
|  |       "LANGUAGE": "Language", | ||||||
|  |       "CATEGORY": "Category", | ||||||
|  |       "VARIABLE_PLACEHOLDER": "Enter {variable} value", | ||||||
|  |       "GO_BACK_LABEL": "Go Back", | ||||||
|  |       "SEND_MESSAGE_LABEL": "Send Message", | ||||||
|  |       "FORM_ERROR_MESSAGE": "Please fill all variables before sending", | ||||||
|  |       "MEDIA_HEADER_LABEL": "{type} Header", | ||||||
|  |       "MEDIA_URL_LABEL": "Enter full media URL", | ||||||
|  |       "MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg" | ||||||
|  |     }, | ||||||
|  |     "FORM": { | ||||||
|  |       "BACK_BUTTON": "Back", | ||||||
|  |       "SEND_MESSAGE_BUTTON": "Send Message" | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -35,6 +35,7 @@ import signup from './signup.json'; | |||||||
| import sla from './sla.json'; | import sla from './sla.json'; | ||||||
| import teamsSettings from './teamsSettings.json'; | import teamsSettings from './teamsSettings.json'; | ||||||
| import whatsappTemplates from './whatsappTemplates.json'; | import whatsappTemplates from './whatsappTemplates.json'; | ||||||
|  | import contentTemplates from './contentTemplates.json'; | ||||||
|  |  | ||||||
| export default { | export default { | ||||||
|   ...advancedFilters, |   ...advancedFilters, | ||||||
| @@ -74,4 +75,5 @@ export default { | |||||||
|   ...sla, |   ...sla, | ||||||
|   ...teamsSettings, |   ...teamsSettings, | ||||||
|   ...whatsappTemplates, |   ...whatsappTemplates, | ||||||
|  |   ...contentTemplates, | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -125,6 +125,19 @@ export default { | |||||||
|     > |     > | ||||||
|       <woot-code :script="inbox.callback_webhook_url" lang="html" /> |       <woot-code :script="inbox.callback_webhook_url" lang="html" /> | ||||||
|     </SettingsSection> |     </SettingsSection> | ||||||
|  |     <SettingsSection | ||||||
|  |       v-if="isATwilioWhatsAppChannel" | ||||||
|  |       :title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')" | ||||||
|  |       :sub-title=" | ||||||
|  |         $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER') | ||||||
|  |       " | ||||||
|  |     > | ||||||
|  |       <div class="flex justify-start items-center mt-2"> | ||||||
|  |         <NextButton :disabled="isSyncingTemplates" @click="syncTemplates"> | ||||||
|  |           {{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }} | ||||||
|  |         </NextButton> | ||||||
|  |       </div> | ||||||
|  |     </SettingsSection> | ||||||
|   </div> |   </div> | ||||||
|   <div v-else-if="isAVoiceChannel" class="mx-8"> |   <div v-else-if="isAVoiceChannel" class="mx-8"> | ||||||
|     <SettingsSection |     <SettingsSection | ||||||
|   | |||||||
| @@ -162,3 +162,9 @@ export const ATTACHMENT_ICONS = { | |||||||
|   location: 'location', |   location: 'location', | ||||||
|   fallback: 'link', |   fallback: 'link', | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | export const TWILIO_CONTENT_TEMPLATE_TYPES = { | ||||||
|  |   TEXT: 'text', | ||||||
|  |   MEDIA: 'media', | ||||||
|  |   QUICK_REPLY: 'quick_reply', | ||||||
|  | }; | ||||||
|   | |||||||
| @@ -17,7 +17,15 @@ class Twilio::TemplateSyncService | |||||||
|   end |   end | ||||||
|  |  | ||||||
|   def update_channel_templates |   def update_channel_templates | ||||||
|     formatted_templates = @templates.map do |template| |     formatted_templates = @templates.map { |template| format_template(template) } | ||||||
|  |  | ||||||
|  |     channel.update!( | ||||||
|  |       content_templates: { templates: formatted_templates }, | ||||||
|  |       content_templates_last_updated: Time.current | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def format_template(template) | ||||||
|     { |     { | ||||||
|       content_sid: template.sid, |       content_sid: template.sid, | ||||||
|       friendly_name: template.friendly_name, |       friendly_name: template.friendly_name, | ||||||
| @@ -28,17 +36,12 @@ class Twilio::TemplateSyncService | |||||||
|       variables: template.variables || {}, |       variables: template.variables || {}, | ||||||
|       category: derive_category(template), |       category: derive_category(template), | ||||||
|       body: extract_body_content(template), |       body: extract_body_content(template), | ||||||
|  |       types: template.types, | ||||||
|       created_at: template.date_created, |       created_at: template.date_created, | ||||||
|       updated_at: template.date_updated |       updated_at: template.date_updated | ||||||
|     } |     } | ||||||
|   end |   end | ||||||
|  |  | ||||||
|     channel.update!( |  | ||||||
|       content_templates: { templates: formatted_templates }, |  | ||||||
|       content_templates_last_updated: Time.current |  | ||||||
|     ) |  | ||||||
|   end |  | ||||||
|  |  | ||||||
|   def mark_templates_updated |   def mark_templates_updated | ||||||
|     channel.update!(content_templates_last_updated: Time.current) |     channel.update!(content_templates_last_updated: Time.current) | ||||||
|   end |   end | ||||||
|   | |||||||
| @@ -198,6 +198,7 @@ | |||||||
| - name: twilio_content_templates | - name: twilio_content_templates | ||||||
|   display_name: Twilio Content Templates |   display_name: Twilio Content Templates | ||||||
|   enabled: false |   enabled: false | ||||||
|  |   deprecated: true | ||||||
| - name: advanced_search | - name: advanced_search | ||||||
|   display_name: Advanced Search |   display_name: Advanced Search | ||||||
|   enabled: false |   enabled: false | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Muhsin Keloth
					Muhsin Keloth