diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue index 1e1c996bb..d5e32abfa 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue @@ -73,6 +73,18 @@ export default { cfgCancel: 'Cancelar', errUpdate: 'Erro ao salvar configurações.', successUpdate: 'Configurações atualizadas com sucesso!', + + // Campos Promoção + promoSectionTitle: 'Promoções / IA (Opcional)', + promoAdd: 'Adicionar Promoção', + promoRemove: 'Remover', + promoChannel: 'Canal / Origem (Ex: Instagram)', + promoTitle: 'Ativa', + promoName: 'Nome da Promoção', + promoDesc: 'Descrição / Condições', + promoCoupon: 'Cupom', + promoValid: 'Válida Até (Data)', + promoEmpty: 'Nenhuma promoção configurada para esta Landing Page.', }, }; }, @@ -160,7 +172,31 @@ export default { }, openEdit(host) { this.expandedHostId = host.id; - this.editingHostData = { ...host }; + + let promotions = []; + if ( + host.custom_config?.promotions && + Array.isArray(host.custom_config.promotions) + ) { + promotions = [...host.custom_config.promotions]; + } else if ( + host.custom_config?.promotion && + host.custom_config.promotion.title + ) { + promotions = [{ ...host.custom_config.promotion, channel: 'Geral' }]; + } + + this.editingHostData = { + ...host, + custom_config: { + ...host.custom_config, + promotions: promotions, + }, + }; + + if (this.editingHostData.custom_config.promotion) { + delete this.editingHostData.custom_config.promotion; + } }, cancelEdit() { this.expandedHostId = null; @@ -200,6 +236,22 @@ export default { useAlert(this.labels.errCopy); } }, + addPromotion() { + if (!this.editingHostData.custom_config.promotions) { + this.editingHostData.custom_config.promotions = []; + } + this.editingHostData.custom_config.promotions.push({ + active: true, + channel: '', + title: '', + description: '', + coupon_code: '', + valid_until: '', + }); + }, + removePromotion(index) { + this.editingHostData.custom_config.promotions.splice(index, 1); + }, }, }; @@ -449,6 +501,132 @@ export default { + +
+
+

+ {{ labels.promoSectionTitle }} +

+ +
+ +
+ + +
+ + +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ {{ labels.promoEmpty }} +
+
+
diff --git a/app/models/concerns/landing_host_ai_syncable.rb b/app/models/concerns/landing_host_ai_syncable.rb new file mode 100644 index 000000000..41d77dedd --- /dev/null +++ b/app/models/concerns/landing_host_ai_syncable.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module LandingHostAiSyncable + extend ActiveSupport::Concern + + included do + after_save :sync_promotion_to_faq + end + + def sync_promotion_to_faq + return unless custom_config.is_a?(Hash) + + promotions = custom_config['promotions'] + + # Fallback to legacy format if array is empty but object exists + promotions = [custom_config['promotion']] if promotions.blank? && custom_config['promotion'].is_a?(Hash) + + active_promos = Array(promotions).select { |p| p.is_a?(Hash) && p['active'] } + + if active_promos.any? + create_or_update_faq_article(active_promos) + else + archive_faq_article + end + end + + private + + def create_or_update_faq_article(promos) + return unless can_sync_to_portal? + + article = find_or_initialize_faq_article + article.title = faq_article_title + + # Generating the standard AI instruction text for the multiple promotions + text = %(INSTRUÇÃO PARA A IA (PROMOÇÕES ATIVAS DO LINK #{hostname}):\n\n) + text += %(Existem promoções ativas para os leads que chegam pela landing page '#{hostname}'.\n) + text += %(Ofereça a promoção correspondente ao Canal/Origem pelo qual o cliente chegou.\n\n) + + promos.each do |promo| + channel = promo['channel'].presence || 'Geral' + text += "--- CANAL / ORIGEM: #{channel} ---\n" + text += "Título da Promoção: #{promo['title']}\n" if promo['title'].present? + text += "Condições / Descrição: #{promo['description']}\n" if promo['description'].present? + text += "Cupom: #{promo['coupon_code']}\n" if promo['coupon_code'].present? + text += "Válida até: #{promo['valid_until']}\n" if promo['valid_until'].present? + text += "\n" + end + + article.content = text + article.description = "FAQ Gerado automaticamente pela Landing Page: #{hostname}" + # Setting the author as the portal's account first user (just as a fallback) or we can use a system user + article.author ||= default_article_author + article.status = :published + + article.save! + end + + def archive_faq_article + return unless can_sync_to_portal? + + article = find_faq_article + article&.update!(status: :archived) + end + + def find_or_initialize_faq_article + find_faq_article || portal.articles.new(account_id: inbox.account_id) + end + + def find_faq_article + portal.articles.find_by(title: faq_article_title) + end + + def faq_article_title + "Promoção Automática - #{hostname.upcase}" + end + + def portal + inbox.portal + end + + def can_sync_to_portal? + inbox.present? && inbox.portal_id.present? + end + + def default_article_author + # Assumes that the account has at least one user (owner/admin) to author the article + inbox.account.users.order(id: :asc).first + end +end diff --git a/app/models/landing_host.rb b/app/models/landing_host.rb index aebf01284..b48734983 100644 --- a/app/models/landing_host.rb +++ b/app/models/landing_host.rb @@ -27,4 +27,7 @@ # index_landing_hosts_on_hostname (hostname) UNIQUE # class LandingHost < ApplicationRecord + belongs_to :inbox, optional: true + + include LandingHostAiSyncable end diff --git a/progresso/automacao_mensagem_meta.md b/progresso/automacao_mensagem_meta.md new file mode 100644 index 000000000..58ad86ddc --- /dev/null +++ b/progresso/automacao_mensagem_meta.md @@ -0,0 +1,23 @@ +# Resolução: Automação por Conteúdo da Mensagem (Meta Ads) + +**Objetivo:** Criar uma regra de automação no Chatwoot que aplica uma etiqueta automaticamente quando uma mensagem específica (geralmente vinda de um link do Meta Ads/WhatsApp) é recebida. + +**Contexto e Desafios Encontrados:** +1. **Tradução e Disponibilidade:** Em Português, a condição "Message Content" é traduzida como **"Mensagem contém"**. Porém, ela só aparece se o evento selecionado for **"Mensagem Criada"** (Message Created), e não "Conversa Criada". +2. **Separação por Vírgulas (Bug Silencioso):** O Chatwoot usa o tipo de input `comma_separated_plain_text` para o campo "Mensagem contém". Isso significa que se a frase da campanha contiver uma vírgula (ex: *"Olá, tenho interesse"*), o sistema divide a frase em duas strings distintas e exige que a mensagem recebida seja exatamente igual a uma delas, fazendo com que a automação falhe. + +**Passos para a Resolução:** +1. Criar a Automação e definir o **Evento** como **Mensagem Criada**. +2. Na seção de Condições, escolher **Mensagem contém**. +3. Mudar o operador de "Igual a" para **"Contém"**. +4. No campo de valor, inserir a frase do anúncio **removendo a vírgula** ou colando apenas o trecho antes da vírgula (Ex: `Olá! Tenho interesse e queria mais informações`). +5. Nas Ações, definir **Adicionar Rótulo** e escolher a tag desejada da campanha. + +**Principais Códigos/Arquivos Analisados (Backend/Frontend):** +- `app/javascript/dashboard/routes/dashboard/settings/automation/constants.js`: Onde a condição `content` é mapeada no evento de `message_created`. +- `app/javascript/dashboard/composables/useEditableAutomation.js`: Onde o input `comma_separated_plain_text` é convertido para envio (responsável pelo problema da quebra no texto por vírgulas). +- `app/services/automation_rules/conditions_filter_service.rb` e `app/services/filter_service.rb`: Serviços que processam e validam as condições da automação (a busca é feita no campo banco de dados através da sintaxe `LOWER(messages.processed_message_content)`). + +**Como validar ou reverter:** +- **Validar:** Simular o envio de uma nova mensagem no WhatsApp usando o mesmo formato definido na campanha (antes ou após a vírgula). O painel do Chatwoot deve aplicar o rótulo da campanha de forma simultânea à recepção da mensagem. +- **Reverter:** Pausar a regra no painel em `Configurações > Automação` alternando a chave de 'Ativo', ou excluí-la permanentemente. diff --git a/spec/models/landing_host_spec.rb b/spec/models/landing_host_spec.rb index f8c5ec3ff..e787bfd2c 100644 --- a/spec/models/landing_host_spec.rb +++ b/spec/models/landing_host_spec.rb @@ -1,5 +1,56 @@ require 'rails_helper' RSpec.describe LandingHost, type: :model do - pending "add some examples to (or delete) #{__FILE__}" + let(:account) { create(:account) } + let!(:user) { create(:user, account: account) } + let(:portal) { create(:portal, account: account) } + let(:inbox) { create(:inbox, account: account, portal: portal) } + + describe 'LandingHostAiSyncable' do + let(:landing_host) do + build(:landing_host, + inbox: inbox, + hostname: 'promo.example.com', + custom_config: { + promotions: [ + { + active: true, + channel: 'Instagram', + title: 'Black Friday 50% Off', + description: 'Valid for all suites.', + coupon_code: 'BLACK50', + valid_until: '2024-11-30' + } + ] + }) + end + + it 'creates a new FAQ article when promotion is active' do + expect do + landing_host.save! + end.to change(Article, :count).by(1) + + title = "Promoção Automática - #{landing_host.hostname.upcase}" + article = portal.articles.find_by(title: title) + expect(article).to be_present + expect(article.title).to include("Promoção Automática - #{landing_host.hostname.upcase}") + expect(article.content).to include('Black Friday 50% Off') + expect(article.content).to include('BLACK50') + expect(article.content).to include('Instagram') + expect(article.status).to eq('published') + end + + it 'archives an existing FAQ article when promotion is deactivated' do + landing_host.save! # Automatically creates the article + title = "Promoção Automática - #{landing_host.hostname.upcase}" + article = portal.articles.find_by(title: title) + expect(article.status).to eq('published') + + # Deactivate promotion + landing_host.custom_config['promotions'].first['active'] = false + landing_host.save! + + expect(article.reload.status).to eq('archived') + end + end end