diff --git a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue index 4d526d558..1390ea0f7 100644 --- a/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue +++ b/app/javascript/dashboard/components-next/content-templates/ContentTemplateParser.vue @@ -3,8 +3,13 @@ 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 { + isTwilioComplete, + isTwilioMediaTemplate, + getTwilioMediaVariableKey, + getTwilioMediaUrl, + applyTwilioMediaFilename, +} from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; @@ -40,30 +45,23 @@ const templateBody = computed(() => { return props.template.body || ''; }); -const hasMediaTemplate = computed(() => { - return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA; -}); +// Media-template detection and variable extraction are shared with the mobile +// app via @chatwoot/utils. +const hasMediaTemplate = computed(() => isTwilioMediaTemplate(props.template)); 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 mediaVariableKey = computed(() => + getTwilioMediaVariableKey(props.template) +); -const hasMediaVariable = computed(() => { - return hasMediaTemplate.value && mediaVariableKey.value !== null; -}); +const hasMediaVariable = computed(() => mediaVariableKey.value !== null); -const templateMediaUrl = computed(() => { - if (!hasMediaTemplate.value) return ''; - - return props.template?.types?.['twilio/media']?.media?.[0] || ''; -}); +const templateMediaUrl = computed(() => + hasMediaTemplate.value ? getTwilioMediaUrl(props.template) : '' +); const variablePattern = computed(() => { if (!hasVariables.value) return []; @@ -83,26 +81,10 @@ const renderedTemplate = computed(() => { 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; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isTwilioComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { @@ -135,19 +117,11 @@ const sendMessage = () => { 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] - ); - } + // For media templates, reduce the media URL to a filename before sending. + const processedParameters = applyTwilioMediaFilename( + props.template, + processedParams.value + ); const payload = { message: renderedTemplate.value, diff --git a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue index 6df06642c..620955cb4 100644 --- a/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue +++ b/app/javascript/dashboard/components-next/whatsapp/WhatsAppTemplateParser.vue @@ -13,6 +13,7 @@ import { useVuelidate } from '@vuelidate/core'; import { requiredIf } from '@vuelidate/validators'; import { useI18n } from 'vue-i18n'; +import { isWhatsAppComplete } from '@chatwoot/utils'; import Input from 'dashboard/components-next/input/Input.vue'; import { buildTemplateParameters, @@ -84,29 +85,10 @@ const renderedTemplate = computed(() => { return replaceTemplateVariables(bodyText.value, processedParams.value); }); -const isFormInvalid = computed(() => { - if (!hasVariables.value && !hasMediaHeader.value) return false; - - if (hasMediaHeader.value && !processedParams.value.header?.media_url) { - return true; - } - - if (hasVariables.value && processedParams.value.body) { - const hasEmptyBodyVariable = Object.values(processedParams.value.body).some( - value => !value - ); - if (hasEmptyBodyVariable) return true; - } - - if (processedParams.value.buttons) { - const hasEmptyButtonParameter = processedParams.value.buttons.some( - button => !button.parameter - ); - if (hasEmptyButtonParameter) return true; - } - - return false; -}); +// Completeness validation is shared with the mobile app via @chatwoot/utils. +const isFormInvalid = computed( + () => !isWhatsAppComplete(props.template, processedParams.value) +); const v$ = useVuelidate( { diff --git a/app/javascript/dashboard/helper/URLHelper.js b/app/javascript/dashboard/helper/URLHelper.js index 76a5d8bd4..8437e116f 100644 --- a/app/javascript/dashboard/helper/URLHelper.js +++ b/app/javascript/dashboard/helper/URLHelper.js @@ -127,25 +127,8 @@ export const getHostNameFromURL = url => { } }; -/** - * 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; - } -}; +// Shared with the mobile app via @chatwoot/utils. +export { extractFilenameFromUrl } from '@chatwoot/utils'; /** * Normalizes a comma/newline separated list of domains diff --git a/app/javascript/dashboard/helper/specs/templateHelper.spec.js b/app/javascript/dashboard/helper/specs/templateHelper.spec.js index 375e38a2d..ba056c317 100644 --- a/app/javascript/dashboard/helper/specs/templateHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/templateHelper.spec.js @@ -156,12 +156,18 @@ describe('templateHelper', () => { ]); }); - it('should handle templates with no variables', () => { + it('should handle templates with no variables but a media header', () => { const emptyTemplate = templates.find( t => t.name === 'no_variable_template' ); - const result = buildTemplateParameters(emptyTemplate, false); - expect(result).toEqual({}); + const result = buildTemplateParameters(emptyTemplate); + // hasMediaHeader is derived from the template, so the document header is kept. + expect(result.body).toBeUndefined(); + expect(result.header).toEqual({ + media_url: '', + media_type: 'document', + media_name: '', + }); }); it('should build parameters for templates with multiple component types', () => { diff --git a/app/javascript/dashboard/helper/templateHelper.js b/app/javascript/dashboard/helper/templateHelper.js index 1fb61d760..c875871f4 100644 --- a/app/javascript/dashboard/helper/templateHelper.js +++ b/app/javascript/dashboard/helper/templateHelper.js @@ -1,19 +1,16 @@ -// Constants +import { processVariable, buildWhatsAppProcessedParams } from '@chatwoot/utils'; + +// Constants and pure template helpers are shared with the mobile app via +// @chatwoot/utils so the logic lives in one place. +export { + MEDIA_FORMATS, + COMPONENT_TYPES, + findComponentByType, + processVariable, +} from '@chatwoot/utils'; + export const DEFAULT_LANGUAGE = 'en'; export const DEFAULT_CATEGORY = 'UTILITY'; -export const COMPONENT_TYPES = { - HEADER: 'HEADER', - BODY: 'BODY', - BUTTONS: 'BUTTONS', -}; -export const MEDIA_FORMATS = ['IMAGE', 'VIDEO', 'DOCUMENT']; - -export const findComponentByType = (template, type) => - template.components?.find(component => component.type === type); - -export const processVariable = str => { - return str.replace(/{{|}}/g, ''); -}; export const allKeysRequired = value => { const keys = Object.keys(value); @@ -27,70 +24,7 @@ export const replaceTemplateVariables = (templateText, processedParams) => { }); }; -export const buildTemplateParameters = (template, hasMediaHeaderValue) => { - const allVariables = {}; - - const bodyComponent = findComponentByType(template, COMPONENT_TYPES.BODY); - const headerComponent = findComponentByType(template, COMPONENT_TYPES.HEADER); - - if (!bodyComponent) return allVariables; - - const templateString = bodyComponent.text; - - // Process body variables - const matchedVariables = templateString.match(/{{([^}]+)}}/g); - if (matchedVariables) { - allVariables.body = {}; - matchedVariables.forEach(variable => { - const key = processVariable(variable); - allVariables.body[key] = ''; - }); - } - - if (hasMediaHeaderValue) { - if (!allVariables.header) allVariables.header = {}; - allVariables.header.media_url = ''; - allVariables.header.media_type = headerComponent.format.toLowerCase(); - - // For document templates, include media_name field for filename support - if (headerComponent.format.toLowerCase() === 'document') { - allVariables.header.media_name = ''; - } - } - - // Process button variables - const buttonComponents = template.components.filter( - component => component.type === COMPONENT_TYPES.BUTTONS - ); - - buttonComponents.forEach(buttonComponent => { - if (buttonComponent.buttons) { - buttonComponent.buttons.forEach((button, index) => { - // Handle URL buttons with variables - if (button.type === 'URL' && button.url && button.url.includes('{{')) { - const buttonVars = button.url.match(/{{([^}]+)}}/g) || []; - if (buttonVars.length > 0) { - if (!allVariables.buttons) allVariables.buttons = []; - allVariables.buttons[index] = { - type: 'url', - parameter: '', - url: button.url, - variables: buttonVars.map(v => processVariable(v)), - }; - } - } - - // Handle copy code buttons - if (button.type === 'COPY_CODE') { - if (!allVariables.buttons) allVariables.buttons = []; - allVariables.buttons[index] = { - type: 'copy_code', - parameter: '', - }; - } - }); - } - }); - - return allVariables; -}; +// The media-header flag is derived from the template inside the shared helper; +// the second argument is kept for backwards-compatible call sites. +export const buildTemplateParameters = template => + buildWhatsAppProcessedParams(template); diff --git a/app/javascript/dashboard/store/modules/inboxes.js b/app/javascript/dashboard/store/modules/inboxes.js index ce24ef653..64a29c215 100644 --- a/app/javascript/dashboard/store/modules/inboxes.js +++ b/app/javascript/dashboard/store/modules/inboxes.js @@ -7,6 +7,7 @@ import FBChannel from '../../api/channel/fbChannel'; import TwilioChannel from '../../api/channel/twilioChannel'; import WhatsappChannel from '../../api/channel/whatsappChannel'; import { throwErrorMessage } from '../utils/api'; +import { isSendableTemplate } from '@chatwoot/utils'; import AnalyticsHelper from '../../helper/AnalyticsHelper'; import camelcaseKeys from 'camelcase-keys'; import { ACCOUNT_EVENTS } from '../../helper/AnalyticsHelper/events'; @@ -67,45 +68,8 @@ export const getters = { return []; } - return templates.filter(template => { - // Ensure template has required properties - if (!template || !template.status || !template.components) { - return false; - } - - // Only show approved templates - if (template.status.toLowerCase() !== 'approved') { - return false; - } - - // Filter out authentication templates - if (template.category === 'AUTHENTICATION') { - return false; - } - - // Filter out CSAT templates (customer_satisfaction_survey and its versions) - if ( - template.name && - template.name.startsWith('customer_satisfaction_survey') - ) { - return false; - } - - // Filter out interactive templates (LIST, PRODUCT, CATALOG), location templates, and call permission templates - const hasUnsupportedComponents = template.components.some( - component => - ['LIST', 'PRODUCT', 'CATALOG', 'CALL_PERMISSION_REQUEST'].includes( - component.type - ) || - (component.type === 'HEADER' && component.format === 'LOCATION') - ); - - if (hasUnsupportedComponents) { - return false; - } - - return true; - }); + // Sendable-template filtering is shared with the mobile app via @chatwoot/utils. + return templates.filter(isSendableTemplate); }, getNewConversationInboxes($state) { return $state.records.filter(inbox => { diff --git a/package.json b/package.json index bf00dde8a..c699787f2 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", "@chatwoot/prosemirror-schema": "1.3.22", - "@chatwoot/utils": "^0.0.55", + "@chatwoot/utils": "^0.0.56", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", "@hcaptcha/vue3-hcaptcha": "^1.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ffb85c18..40f84477a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -28,8 +28,8 @@ importers: specifier: 1.3.22 version: 1.3.22 '@chatwoot/utils': - specifier: ^0.0.55 - version: 0.0.55 + specifier: ^0.0.56 + version: 0.0.56 '@formkit/core': specifier: ^1.7.2 version: 1.7.2 @@ -464,8 +464,8 @@ packages: '@chatwoot/prosemirror-schema@1.3.22': resolution: {integrity: sha512-0r+PT8xhQLCKCpoV9k9XVTTRECs/0Nr37wbcLsRS7yvc7WkF9FY05z2hGCRJReWmTOcmmshHtb042LVP+MyB/w==} - '@chatwoot/utils@0.0.55': - resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} + '@chatwoot/utils@0.0.56': + resolution: {integrity: sha512-A6dmPLfTSrW4qYNY73btyi4PqpfzcXRSaucscZTQdzNqF6G/QUdgnBmHtho8HeiYby/kSHXaSxLJj+0dx3yEQQ==} engines: {node: '>=10'} '@codemirror/commands@6.7.0': @@ -5154,7 +5154,7 @@ snapshots: prosemirror-utils: 1.2.2(prosemirror-model@1.22.3)(prosemirror-state@1.4.3) prosemirror-view: 1.34.1 - '@chatwoot/utils@0.0.55': + '@chatwoot/utils@0.0.56': dependencies: date-fns: 2.30.0