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',
|
||||
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);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -449,6 +501,132 @@ export default {
|
||||
</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
|
||||
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
|
||||
#
|
||||
class LandingHost < ApplicationRecord
|
||||
belongs_to :inbox, optional: true
|
||||
|
||||
include LandingHostAiSyncable
|
||||
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'
|
||||
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user