feat: Implementa a sincronização automática de promoções do LandingHost para artigos de FAQ, permitindo a criação, atualização e arquivamento de conteúdo baseado em configurações de promoções.
This commit is contained in:
parent
c16194eff9
commit
7e23e59782
@ -73,6 +73,18 @@ export default {
|
|||||||
cfgCancel: 'Cancelar',
|
cfgCancel: 'Cancelar',
|
||||||
errUpdate: 'Erro ao salvar configurações.',
|
errUpdate: 'Erro ao salvar configurações.',
|
||||||
successUpdate: 'Configurações atualizadas com sucesso!',
|
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) {
|
openEdit(host) {
|
||||||
this.expandedHostId = host.id;
|
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() {
|
cancelEdit() {
|
||||||
this.expandedHostId = null;
|
this.expandedHostId = null;
|
||||||
@ -200,6 +236,22 @@ export default {
|
|||||||
useAlert(this.labels.errCopy);
|
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);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
@ -449,6 +501,132 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Seção de Promoções (Para IA) -->
|
||||||
|
<div class="mt-4 pt-4 border-t border-n-slate-2">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h4 class="text-sm font-semibold text-n-slate-12">
|
||||||
|
{{ labels.promoSectionTitle }}
|
||||||
|
</h4>
|
||||||
|
<button
|
||||||
|
class="text-xs font-medium text-emerald-700 bg-emerald-50 hover:bg-emerald-100 px-3 py-1.5 rounded-md transition-colors flex items-center gap-1 border border-emerald-200"
|
||||||
|
@click.prevent="addPromotion"
|
||||||
|
>
|
||||||
|
<span class="text-base leading-none font-bold">+</span>
|
||||||
|
{{ labels.promoAdd }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="(promo, index) in editingHostData.custom_config
|
||||||
|
.promotions"
|
||||||
|
:key="index"
|
||||||
|
class="bg-n-brand-1/10 p-5 rounded-lg border border-n-brand-2 mb-4 relative"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute top-4 right-4 text-xs font-medium text-ruby-9 hover:text-ruby-11 transition-colors z-10"
|
||||||
|
@click.prevent="removePromotion(index)"
|
||||||
|
>
|
||||||
|
{{ labels.promoRemove }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="mb-4 flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
:id="'promo-toggle-' + editingHostData.id + '-' + index"
|
||||||
|
v-model="promo.active"
|
||||||
|
type="checkbox"
|
||||||
|
class="h-4 w-4 rounded border-gray-300 text-n-brand cursor-pointer"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
:for="
|
||||||
|
'promo-toggle-' + editingHostData.id + '-' + index
|
||||||
|
"
|
||||||
|
class="text-sm font-medium text-n-slate-12 cursor-pointer"
|
||||||
|
>
|
||||||
|
{{ labels.promoTitle }}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"
|
||||||
|
:class="{
|
||||||
|
'opacity-50 pointer-events-none grayscale':
|
||||||
|
!promo.active,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-n-slate-11 mb-1"
|
||||||
|
>
|
||||||
|
{{ labels.promoChannel }} *
|
||||||
|
</label>
|
||||||
|
<woot-input
|
||||||
|
v-model="promo.channel"
|
||||||
|
placeholder="Ex: Instagram"
|
||||||
|
class="[&>input]:!mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-n-slate-11 mb-1"
|
||||||
|
>
|
||||||
|
{{ labels.promoName }}
|
||||||
|
</label>
|
||||||
|
<woot-input
|
||||||
|
v-model="promo.title"
|
||||||
|
placeholder="Ex: Oferta Fim de Semana"
|
||||||
|
class="[&>input]:!mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-n-slate-11 mb-1"
|
||||||
|
>
|
||||||
|
{{ labels.promoDesc }}
|
||||||
|
</label>
|
||||||
|
<woot-input
|
||||||
|
v-model="promo.description"
|
||||||
|
placeholder="Ex: 20% OFF na reserva sexta ou sábado. Válido para novos clientes."
|
||||||
|
class="[&>input]:!mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-n-slate-11 mb-1"
|
||||||
|
>
|
||||||
|
{{ labels.promoCoupon }}
|
||||||
|
</label>
|
||||||
|
<woot-input
|
||||||
|
v-model="promo.coupon_code"
|
||||||
|
placeholder="Ex: BLACK20"
|
||||||
|
class="[&>input]:!mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label
|
||||||
|
class="block text-xs font-medium text-n-slate-11 mb-1"
|
||||||
|
>
|
||||||
|
{{ labels.promoValid }}
|
||||||
|
</label>
|
||||||
|
<woot-input
|
||||||
|
v-model="promo.valid_until"
|
||||||
|
type="date"
|
||||||
|
class="[&>input]:!mb-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="
|
||||||
|
!editingHostData.custom_config.promotions ||
|
||||||
|
editingHostData.custom_config.promotions.length === 0
|
||||||
|
"
|
||||||
|
class="text-sm text-n-slate-10 italic py-4 text-center border border-dashed border-n-slate-3 rounded-lg"
|
||||||
|
>
|
||||||
|
{{ labels.promoEmpty }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="mt-4 flex justify-end gap-3 border-t border-n-slate-2 pt-4"
|
class="mt-4 flex justify-end gap-3 border-t border-n-slate-2 pt-4"
|
||||||
>
|
>
|
||||||
|
|||||||
90
app/models/concerns/landing_host_ai_syncable.rb
Normal file
90
app/models/concerns/landing_host_ai_syncable.rb
Normal file
@ -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
|
||||||
@ -27,4 +27,7 @@
|
|||||||
# index_landing_hosts_on_hostname (hostname) UNIQUE
|
# index_landing_hosts_on_hostname (hostname) UNIQUE
|
||||||
#
|
#
|
||||||
class LandingHost < ApplicationRecord
|
class LandingHost < ApplicationRecord
|
||||||
|
belongs_to :inbox, optional: true
|
||||||
|
|
||||||
|
include LandingHostAiSyncable
|
||||||
end
|
end
|
||||||
|
|||||||
23
progresso/automacao_mensagem_meta.md
Normal file
23
progresso/automacao_mensagem_meta.md
Normal file
@ -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.
|
||||||
@ -1,5 +1,56 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe LandingHost, type: :model do
|
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
|
end
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user