From 4610cff67e546cbdea4cea59f24b0d651bd1e3ad Mon Sep 17 00:00:00 2001 From: Muhsin Keloth Date: Mon, 21 Jul 2025 21:25:44 +0400 Subject: [PATCH] chore: add more templates --- .../components/WhatsappTemplateParser.vue | 396 +++++++++++++-- .../WhatsappTemplates/TemplateParser.vue | 133 ++++- .../specs/whatsappTemplates/fixtures.js | 21 + .../providers/whatsapp_cloud_service.rb | 86 +++- .../whatsapp/template_processor_service.rb | 467 ++++++++++++++++-- 5 files changed, 994 insertions(+), 109 deletions(-) diff --git a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue index eb1828257..12cfbf39f 100644 --- a/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue +++ b/app/javascript/dashboard/components-next/NewConversation/components/WhatsappTemplateParser.vue @@ -53,6 +53,21 @@ const buttonComponents = computed(() => { ); }); +const interactiveComponents = computed(() => { + return templateComponents.value.filter( + component => ['LIST', 'PRODUCT', 'CATALOG'].includes(component.type) + ); +}); + +const isInteractiveTemplate = computed(() => { + const hasInteractiveButtons = buttonComponents.value.some(component => + component.buttons?.some(button => + ['quick_reply', 'url', 'phone_number', 'catalog_browse'].includes(button.type) + ) + ); + return hasInteractiveButtons || interactiveComponents.value.length > 0; +}); + const legacyParams = computed(() => { const params = {}; Object.keys(processedParams.value).forEach(key => { @@ -90,7 +105,17 @@ const processedStringWithVariableHighlight = computed(() => { const rules = computed(() => { const paramRules = {}; Object.keys(processedParams.value).forEach(key => { - paramRules[key] = { required: requiredIf(true) }; + if (key === 'header' && processedParams.value.header.location_type === 'location') { + // Add specific validation for location parameters + paramRules[key] = { + location: { + latitude: { required: requiredIf(true) }, + longitude: { required: requiredIf(true) } + } + }; + } else { + paramRules[key] = { required: requiredIf(true) }; + } }); return { processedParams: paramRules, @@ -106,45 +131,89 @@ const getFieldErrorType = key => { const generateVariables = () => { const allVariables = {}; + + // Debug: Log template structure + console.log('Template components:', templateComponents.value); + console.log('Header component:', headerComponent.value); // Process body variables const bodyVars = templateString.value.match(/{{([^}]+)}}/g) || []; - bodyVars.forEach(variable => { - const key = processVariable(variable); - if (!allVariables.body) allVariables.body = {}; - allVariables.body[key] = ''; - }); + if (bodyVars.length > 0) { + allVariables.body = {}; + bodyVars.forEach(variable => { + const key = processVariable(variable); + // Special handling for authentication templates + if (props.template?.category === 'AUTHENTICATION') { + if ( + key === '1' || + key.toLowerCase().includes('otp') || + key.toLowerCase().includes('code') + ) { + allVariables.body.otp_code = ''; + } else if ( + key === '2' || + key.toLowerCase().includes('expiry') || + key.toLowerCase().includes('minute') + ) { + allVariables.body.expiry_minutes = ''; + } else { + allVariables.body[key] = ''; + } + } else { + allVariables.body[key] = ''; + } + }); + } // Process header variables if (headerComponent.value) { if (headerComponent.value.text) { // Text headers with variables const headerVars = headerComponent.value.text.match(/{{([^}]+)}}/g) || []; - headerVars.forEach(variable => { - const key = processVariable(variable); - if (!allVariables.header) allVariables.header = {}; - allVariables.header[key] = ''; - }); + if (headerVars.length > 0) { + allVariables.header = {}; + headerVars.forEach(variable => { + const key = processVariable(variable); + allVariables.header[key] = ''; + }); + } } else if ( headerComponent.value.format && ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerComponent.value.format) ) { // Media headers need URL input - if (!allVariables.header) allVariables.header = {}; - allVariables.header.media_url = ''; - allVariables.header.media_type = - headerComponent.value.format.toLowerCase(); + allVariables.header = { + media_url: '', + media_type: headerComponent.value.format.toLowerCase(), + }; + } else if ( + headerComponent.value.format && + headerComponent.value.format === 'LOCATION' + ) { + // Location headers need location data + console.log('Detected LOCATION header template:', headerComponent.value); + allVariables.header = { + location: { + latitude: '', + longitude: '', + name: '', + address: '', + }, + location_type: 'location', + }; } } // Process footer variables if (footerComponent.value?.text) { const footerVars = footerComponent.value.text.match(/{{([^}]+)}}/g) || []; - footerVars.forEach(variable => { - const key = processVariable(variable); - if (!allVariables.footer) allVariables.footer = {}; - allVariables.footer[key] = ''; - }); + if (footerVars.length > 0) { + allVariables.footer = {}; + footerVars.forEach(variable => { + const key = processVariable(variable); + allVariables.footer[key] = ''; + }); + } } // Process button variables @@ -154,25 +223,62 @@ const generateVariables = () => { // Handle URL buttons with variables if (button.url && button.url.includes('{{')) { const buttonVars = button.url.match(/{{([^}]+)}}/g) || []; - buttonVars.forEach(() => { + if (buttonVars.length > 0) { if (!allVariables.buttons) allVariables.buttons = []; - if (!allVariables.buttons[index]) allVariables.buttons[index] = {}; - allVariables.buttons[index].type = 'url'; - allVariables.buttons[index].parameter = ''; - }); + allVariables.buttons[index] = { + type: 'url', + parameter: '', + }; + } } // Handle copy code buttons if (button.type === 'COPY_CODE') { if (!allVariables.buttons) allVariables.buttons = []; - if (!allVariables.buttons[index]) allVariables.buttons[index] = {}; - allVariables.buttons[index].type = 'copy_code'; - allVariables.buttons[index].parameter = ''; + allVariables.buttons[index] = { + type: 'copy_code', + parameter: '', + }; + } + + // Handle interactive buttons with dynamic text + if (['quick_reply', 'url', 'phone_number'].includes(button.type)) { + if (button.text && button.text.includes('{{')) { + if (!allVariables.buttons) allVariables.buttons = []; + allVariables.buttons[index] = { + type: button.type, + parameter: '', + text: button.text, + }; + } } }); } }); + // Process interactive components (LIST, PRODUCT, CATALOG) + interactiveComponents.value.forEach(component => { + if (component.type === 'LIST') { + allVariables.interactive = { + type: 'list', + button_text: 'Select Option', + sections: component.sections || [] + }; + } else if (component.type === 'PRODUCT') { + allVariables.interactive = { + type: 'product', + catalog_id: component.catalog_id || '', + product_id: component.product_id || '' + }; + } else if (component.type === 'CATALOG') { + allVariables.interactive = { + type: 'catalog', + catalog_id: component.catalog_id || '', + title: component.title || 'Browse Products' + }; + } + }); + processedParams.value = allVariables; }; @@ -227,6 +333,70 @@ const getHeaderFieldPlaceholder = key => { return `Enter ${key} value`; }; +const getBodyParameterLabel = key => { + if (props.template?.category === 'AUTHENTICATION') { + switch (key) { + case 'otp_code': + return t('WHATSAPP_TEMPLATES.PARSER.OTP_CODE') || 'OTP Code'; + case 'expiry_minutes': + return ( + t('WHATSAPP_TEMPLATES.PARSER.EXPIRY_MINUTES') || 'Expiry (minutes)' + ); + default: + return key; + } + } + return key; +}; + +const getBodyParameterPlaceholder = key => { + if (props.template?.category === 'AUTHENTICATION') { + switch (key) { + case 'otp_code': + return ( + t('WHATSAPP_TEMPLATES.PARSER.OTP_CODE_PLACEHOLDER') || + 'Enter 4-8 digit OTP code' + ); + case 'expiry_minutes': + return ( + t('WHATSAPP_TEMPLATES.PARSER.EXPIRY_MINUTES_PLACEHOLDER') || + 'Enter expiry time in minutes' + ); + default: + return `Enter ${key} value`; + } + } + return `Enter ${key} value`; +}; + +const getBodyParameterType = key => { + if (props.template?.category === 'AUTHENTICATION') { + switch (key) { + case 'otp_code': + return 'tel'; + case 'expiry_minutes': + return 'number'; + default: + return 'text'; + } + } + return 'text'; +}; + +const getBodyParameterMaxLength = key => { + if (props.template?.category === 'AUTHENTICATION') { + switch (key) { + case 'otp_code': + return 8; + case 'expiry_minutes': + return 3; + default: + return null; + } + } + return null; +}; + const sendMessage = async () => { const isValid = await v$.value.$validate(); if (!isValid) return; @@ -261,6 +431,7 @@ onMounted(() => { ) }} +

{ 'Header Parameters' }} +

+
+
+
+
+ Location Details +
+ +
+
+ + + Range: -90.0 to 90.0 +
+
+ + + Range: -180.0 to 180.0 +
+
+ +
+ + +
+ +
+ + +
+ +
+ 💡 Tip: You can get coordinates by searching your location on Google Maps, + right-clicking the pin, and copying the latitude/longitude values. +
+
+ + +
+
@@ -303,6 +556,7 @@ onMounted(() => { :placeholder="getHeaderFieldPlaceholder(key)" :type="key === 'media_url' ? 'url' : 'text'" /> +
@@ -321,13 +575,16 @@ onMounted(() => { - {{ key }} + {{ getBodyParameterLabel(key) }} @@ -359,6 +616,81 @@ onMounted(() => { + +
+

+ {{ + t('WHATSAPP_TEMPLATES.PARSER.INTERACTIVE_PARAMETERS') || + 'Interactive Parameters' + }} +

+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ List sections are configured in the template and cannot be modified here. +
+
+
+

diff --git a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue index 0630655de..23f070867 100644 --- a/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue +++ b/app/javascript/dashboard/components/widgets/conversation/WhatsappTemplates/TemplateParser.vue @@ -43,6 +43,20 @@ export default { ).text; }); + const headerComponent = computed(() => { + return props.template.components.find( + component => component.type === 'HEADER' + ); + }); + + const hasMediaHeader = computed(() => { + return ( + headerComponent.value && + headerComponent.value.format && + ['IMAGE', 'VIDEO', 'DOCUMENT'].includes(headerComponent.value.format) + ); + }); + const variables = computed(() => { return templateString.value.match(/{{([^}]+)}}/g); }); @@ -65,14 +79,23 @@ export default { ); const generateVariables = () => { - const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); - if (!matchedVariables) return; + const params = {}; - const finalVars = matchedVariables.map(i => processVariable(i)); - processedParams.value = finalVars.reduce((acc, variable) => { - acc[variable] = ''; - return acc; - }, {}); + // Add media URL field if template has media header + if (hasMediaHeader.value) { + params.media_url = ''; + } + + // Add body variables + const matchedVariables = templateString.value.match(/{{([^}]+)}}/g); + if (matchedVariables) { + const finalVars = matchedVariables.map(i => processVariable(i)); + finalVars.forEach(variable => { + params[variable] = ''; + }); + } + + processedParams.value = params; }; const resetTemplate = () => { @@ -83,6 +106,29 @@ export default { v$.value.$touch(); if (v$.value.$invalid) return; + // Prepare enhanced template parameters for media support + const enhancedParams = {}; + + // Handle media header + if (hasMediaHeader.value && processedParams.value.media_url) { + enhancedParams.header = { + media_url: processedParams.value.media_url, + media_type: headerComponent.value.format.toLowerCase(), + }; + } + + // Handle body variables + const bodyParams = {}; + Object.keys(processedParams.value).forEach(key => { + if (key !== 'media_url') { + bodyParams[key] = processedParams.value[key]; + } + }); + + if (Object.keys(bodyParams).length > 0) { + enhancedParams.body = bodyParams; + } + const payload = { message: processedString.value, templateParams: { @@ -90,7 +136,7 @@ export default { category: props.template.category, language: props.template.language, namespace: props.template.namespace, - processed_params: processedParams.value, + processed_params: enhancedParams, }, }; emit('sendMessage', payload); @@ -103,6 +149,8 @@ export default { variables, templateString, processedString, + headerComponent, + hasMediaHeader, v$, resetTemplate, sendMessage, @@ -119,26 +167,57 @@ export default { readonly class="template-input" /> -
-

- {{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }} -

-
- + +
+

+ {{ + headerComponent.format.charAt(0) + + headerComponent.format.slice(1).toLowerCase() + }} + Header +

+
+ + {{ headerComponent.format.toLowerCase() }} URL + + +
+
+ + +
+

+ {{ $t('WHATSAPP_TEMPLATES.PARSER.VARIABLES_LABEL') }} +

+
- {{ key }} - - + + +

header_component['format'].downcase, + header_component['format'].downcase => { + link: media_url + } + }] + } + + # Add body parameters if any + body_params = template_params['processed_params'].map { |_, value| { type: 'text', text: value } } + components << { type: 'body', parameters: body_params } if body_params.present? + + @template_params = components + end + + def process_location_template_params(_template, header_component) + # For templates with location headers + components = [] + + # Add location header component + # Location headers typically include latitude, longitude, name, and address + location_params = if template_params['processed_params'].is_a?(Hash) && template_params['processed_params']['header'] + build_location_parameter(template_params['processed_params']['header']) + else + # Use example location if available, otherwise create placeholder + build_default_location_parameter(header_component) + end + + if location_params + components << { + type: 'header', + parameters: [location_params] + } + end + + # Add body parameters if any + if template_params['processed_params'].present? && !template_params['processed_params'].is_a?(Hash) + body_params = template_params['processed_params'].map { |_, value| { type: 'text', text: value } } + components << { type: 'body', parameters: body_params } if body_params.present? + end + + @template_params = components + end + + def process_authentication_template_params(_template) + # Authentication templates typically have OTP codes and expiration times + components = [] + + # Process body parameters for authentication templates + if template_params['processed_params'].present? + body_params = if template_params['processed_params'].is_a?(Hash) + process_authentication_body_params(template_params['processed_params']) + else + template_params['processed_params'].map { |_, value| { type: 'text', text: value } } + end + components << { type: 'body', parameters: body_params } if body_params.present? + end + + @template_params = components + end + def process_enhanced_template_params(_template) processed_params = template_params['processed_params'] components = [] - # Process body parameters - if processed_params['body'].present? - body_params = processed_params['body'].map { |_, value| build_parameter(value) } - components << { type: 'body', parameters: body_params } - end - - # Process header parameters + # Process header parameters first (important for WhatsApp API order) if processed_params['header'].present? - header_params = processed_params['header'].filter_map do |key, value| + header_params = [] + + processed_params['header'].each do |key, value| + next if value.blank? + if key == 'media_url' && processed_params['header']['media_type'].present? - build_media_parameter(value, processed_params['header']['media_type']) - else - build_parameter(value) + media_param = build_media_parameter(value, processed_params['header']['media_type']) + header_params << media_param if media_param + elsif key == 'location' && processed_params['header']['location_type'] == 'location' + location_param = build_location_parameter(value) + header_params << location_param if location_param + elsif !%w[media_type location_type].include?(key) + header_params << build_parameter(value) end end @@ -108,15 +199,38 @@ class Whatsapp::TemplateProcessorService components << { type: 'header', parameters: header_params } if header_params.present? end + # Process body parameters + if processed_params['body'].present? + body_params = processed_params['body'].filter_map do |key, value| + next if value.blank? + + # Handle special authentication parameters + if key == 'otp_code' + build_authentication_parameter(value, 'otp') + elsif key == 'expiry_minutes' + build_authentication_parameter(value, 'expiry') + else + build_parameter(value) + end + end + components << { type: 'body', parameters: body_params } if body_params.present? + end + # Process footer parameters (rarely used but supported) if processed_params['footer'].present? - footer_params = processed_params['footer'].map { |_, value| build_parameter(value) } - components << { type: 'footer', parameters: footer_params } + footer_params = processed_params['footer'].filter_map do |_, value| + next if value.blank? + + build_parameter(value) + end + components << { type: 'footer', parameters: footer_params } if footer_params.present? end # Process button parameters if processed_params['buttons'].present? - button_params = processed_params['buttons'].map.with_index do |button, index| + button_params = processed_params['buttons'].filter_map.with_index do |button, index| + next if button.blank? || button['parameter'].blank? + { type: 'button', sub_type: button['type'] || 'url', @@ -124,32 +238,33 @@ class Whatsapp::TemplateProcessorService parameters: [build_button_parameter(button)] } end - components.concat(button_params) + components.concat(button_params) if button_params.present? end - components + @template_params = components end def process_legacy_template_params(template) parameter_format = template['parameter_format'] - if parameter_format == 'NAMED' - template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } } - else - template_params['processed_params']&.map { |_, value| { type: 'text', text: value } } - end + @template_params = if parameter_format == 'NAMED' + template_params['processed_params']&.map { |key, value| { type: 'text', parameter_name: key, text: value } } + else + template_params['processed_params']&.map { |_, value| { type: 'text', text: value } } + end + @template_params end def build_parameter(value) case value when String sanitized_value = sanitize_parameter(value) - if sanitized_value.match?(%r{^https?://}) - validate_url(sanitized_value) - # URL parameter (for media or documents) - { type: 'image', image: { link: sanitized_value } } + # Check if this is rich text formatting + if has_rich_formatting?(sanitized_value) + build_rich_text_parameter(sanitized_value) else - # Text parameter + # For regular template parameters, always treat as text + # Media parameters are handled separately via build_media_parameter { type: 'text', text: sanitized_value } end when Hash @@ -194,6 +309,8 @@ class Whatsapp::TemplateProcessorService end def build_button_parameter(button) + return { type: 'text', text: '' } if button.blank? || button['parameter'].blank? + case button['type'] when 'copy_code' coupon_code = button['parameter'].to_s.strip @@ -205,7 +322,8 @@ class Whatsapp::TemplateProcessorService coupon_code: coupon_code } else - build_parameter(button['parameter']) + # For URL buttons and other button types, treat parameter as text + { type: 'text', text: button['parameter'].to_s.strip } end end @@ -217,11 +335,13 @@ class Whatsapp::TemplateProcessorService end def validate_url(url) + return if url.blank? + uri = URI.parse(url) - raise ArgumentError, 'Invalid URL scheme' unless %w[http https].include?(uri.scheme) - raise ArgumentError, 'URL too long' if url.length > 2000 - rescue URI::InvalidURIError - raise ArgumentError, 'Invalid URL format' + raise ArgumentError, "Invalid URL scheme: #{uri.scheme}. Only http and https are allowed" unless %w[http https].include?(uri.scheme) + raise ArgumentError, 'URL too long (max 2000 characters)' if url.length > 2000 + rescue URI::InvalidURIError => e + raise ArgumentError, "Invalid URL format: #{e.message}. Please enter a valid image URL like https://example.com/image.jpg" end def build_media_parameter(url, media_type) @@ -257,6 +377,114 @@ class Whatsapp::TemplateProcessorService end end + def build_location_parameter(location_data) + # Location parameter for header components + # Can be a hash with lat/lng or a string address + case location_data + when Hash + # Structured location data + validate_location_data(location_data) + { + type: 'location', + location: { + latitude: location_data['latitude'].to_f, + longitude: location_data['longitude'].to_f, + name: location_data['name'].to_s.strip, + address: location_data['address'].to_s.strip + } + } + when String + # Address string - parse or use as name + address = sanitize_parameter(location_data) + { + type: 'location', + location: { + latitude: 0.0, + longitude: 0.0, + name: address, + address: address + } + } + end + end + + def build_default_location_parameter(header_component) + # Build default location from template example or placeholder + if header_component['example'] && header_component['example']['header_handle'] + example_location = header_component['example']['header_handle'].first + { + type: 'location', + location: { + latitude: 37.7749, + longitude: -122.4194, + name: example_location || 'Business Location', + address: example_location || 'San Francisco, CA' + } + } + else + { + type: 'location', + location: { + latitude: 37.7749, + longitude: -122.4194, + name: 'Business Location', + address: 'San Francisco, CA' + } + } + end + end + + def build_authentication_parameter(value, param_type) + # Authentication-specific parameters + sanitized_value = sanitize_parameter(value) + + case param_type + when 'otp' + # OTP code - typically 4-8 digits + raise ArgumentError, 'OTP code must be numeric' unless sanitized_value.match?(/\A\d+\z/) + raise ArgumentError, 'OTP code must be 4-8 digits' unless sanitized_value.length.between?(4, 8) + + { type: 'text', text: sanitized_value } + when 'expiry' + # Expiry time in minutes + expiry_minutes = sanitized_value.to_i + raise ArgumentError, 'Expiry minutes must be a positive number' unless expiry_minutes > 0 + + { type: 'text', text: expiry_minutes.to_s } + else + { type: 'text', text: sanitized_value } + end + end + + def process_authentication_body_params(processed_params) + # Process authentication-specific body parameters + processed_params.filter_map do |key, value| + next if value.blank? + + case key + when 'otp_code' + build_authentication_parameter(value, 'otp') + when 'expiry_minutes' + build_authentication_parameter(value, 'expiry') + else + build_parameter(value) + end + end + end + + def validate_location_data(location_data) + required_fields = %w[latitude longitude] + missing_fields = required_fields.reject { |field| location_data.key?(field) } + + raise ArgumentError, "Missing required location fields: #{missing_fields.join(', ')}" if missing_fields.any? + + lat = location_data['latitude'].to_f + lng = location_data['longitude'].to_f + + raise ArgumentError, 'Latitude must be between -90 and 90' unless lat.between?(-90, 90) + raise ArgumentError, 'Longitude must be between -180 and 180' unless lng.between?(-180, 180) + end + def validated_body_object(template) # we don't care if its not approved template return if template['status'] != 'approved' @@ -265,4 +493,175 @@ class Whatsapp::TemplateProcessorService # we don't support other forms of templates template['components'].find { |obj| obj['type'] == 'BODY' && obj.key?('text') } end + + def has_interactive_components?(template) + # Check if template has interactive buttons like quick replies, call-to-actions, etc. + interactive_types = %w[quick_reply url phone_number copy_code list catalog_browse] + template['components']&.any? do |component| + (component['type'] == 'BUTTONS' && component['buttons']&.any? { |button| interactive_types.include?(button['type']) }) || + (component['type'] == 'LIST' && component['sections'].present?) || + (component['type'] == 'PRODUCT' && component['product_id'].present?) || + (component['type'] == 'CATALOG' && component['catalog_id'].present?) + end + end + + def process_interactive_template_params(template) + components = [] + + # Process body parameters + if template_params['processed_params'].present? + body_params = template_params['processed_params'].map { |_, value| { type: 'text', text: value } } + components << { type: 'body', parameters: body_params } if body_params.present? + end + + @template_params = { + type: 'interactive', + components: components, + interactive_data: extract_interactive_data(template) + } + end + + def extract_interactive_data(template) + # Check if this is a catalog browse template + catalog_component = template['components'].find { |c| c['type'] == 'CATALOG' } + return extract_catalog_data(catalog_component) if catalog_component&.dig('catalog_id') + + # Check if this is a product template + product_component = template['components'].find { |c| c['type'] == 'PRODUCT' } + return extract_product_data(product_component) if product_component&.dig('product_id') + + # Check if this is a list template + list_component = template['components'].find { |c| c['type'] == 'LIST' } + return extract_list_data(list_component) if list_component&.dig('sections') + + # Default to button template + interactive_data = { type: 'button', action: { buttons: [] } } + + button_component = template['components'].find { |c| c['type'] == 'BUTTONS' } + return interactive_data unless button_component&.dig('buttons') + + buttons = button_component['buttons'].map.with_index do |button, index| + # Process dynamic button text if parameters are provided + button_text = process_dynamic_button_text(button['text'] || '', index) + + case button['type'] + when 'quick_reply' + { + type: 'reply', + reply: { + id: "reply_#{index}", + title: button_text || "Reply #{index + 1}" + } + } + when 'url' + { + type: 'reply', + reply: { + id: "url_#{index}", + title: button_text || 'Visit Link' + } + } + when 'phone_number' + { + type: 'reply', + reply: { + id: "call_#{index}", + title: button_text || 'Call Now' + } + } + end + end.compact.first(3) # WhatsApp allows max 3 reply buttons + + interactive_data[:action][:buttons] = buttons + interactive_data + end + + def extract_list_data(list_component) + interactive_data = { + type: 'list', + action: { + button: 'Select an Option', + sections: [] + } + } + + sections = list_component['sections'].map.with_index do |section, section_index| + rows = section['rows']&.map&.with_index do |row, row_index| + { + id: "row_#{section_index}_#{row_index}", + title: row['title'] || "Option #{row_index + 1}", + description: row['description'] || nil + } + end&.first(10) || [] # WhatsApp allows max 10 rows per section + + { + title: section['title'] || "Section #{section_index + 1}", + rows: rows + } + end.first(10) # WhatsApp allows max 10 sections + + interactive_data[:action][:sections] = sections + interactive_data + end + + def extract_product_data(product_component) + { + type: 'product', + action: { + catalog_id: product_component['catalog_id'] || '', + product_retailer_id: product_component['product_id'] + } + } + end + + def extract_catalog_data(catalog_component) + { + type: 'product_list', + action: { + catalog_id: catalog_component['catalog_id'], + sections: [ + { + title: catalog_component['title'] || 'Browse Products', + product_items: catalog_component['products']&.map do |product| + { + product_retailer_id: product['id'] || product['product_id'] + } + end || [] + } + ] + } + } + end + + def process_dynamic_button_text(button_text, button_index) + return button_text unless button_text.include?('{{') + + # Replace button text variables with provided parameters + if template_params['processed_params'].present? + button_params = template_params['processed_params']['buttons'] + if button_params.is_a?(Array) && button_params[button_index].present? + button_param = button_params[button_index]['parameter'] || button_params[button_index]['text'] + if button_param.present? + # Replace {{1}}, {{variable}}, etc. with the provided parameter + button_text = button_text.gsub(/\{\{[^}]+\}\}/, button_param.to_s) + end + end + end + + button_text + end + + def has_rich_formatting?(text) + # Check if text contains WhatsApp rich formatting markers + text.match?(/\*[^*]+\*/) || # Bold: *text* + text.match?(/_[^_]+_/) || # Italic: _text_ + text.match?(/~[^~]+~/) || # Strikethrough: ~text~ + text.match?(/```[^`]+```/) # Monospace: ```text``` + end + + def build_rich_text_parameter(text) + # WhatsApp supports rich text formatting in templates + # This preserves the formatting markers for the API + { type: 'text', text: text } + end end