feat: configuração de landing pages por domínio e generalização da galeria

This commit is contained in:
Rodrigo Borba 2026-03-03 11:19:41 -03:00
parent 8d33289a67
commit fe24d381cd
16 changed files with 477 additions and 361 deletions

View File

@ -1,6 +1,6 @@
class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseController class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseController
before_action :fetch_inbox, only: [:index, :create] before_action :fetch_inbox, only: [:index, :create]
before_action :fetch_landing_host, only: [:destroy] before_action :fetch_landing_host, only: [:update, :destroy]
def index def index
@landing_hosts = LandingHost.where(inbox_id: @inbox.id) @landing_hosts = LandingHost.where(inbox_id: @inbox.id)
@ -17,6 +17,14 @@ class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseControl
end end
end end
def update
if @landing_host.update(landing_host_params)
render json: @landing_host
else
render json: { error: @landing_host.errors.full_messages }, status: :unprocessable_entity
end
end
def destroy def destroy
@landing_host.destroy! @landing_host.destroy!
head :no_content head :no_content
@ -31,7 +39,7 @@ class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseControl
end end
def fetch_landing_host def fetch_landing_host
# Garantimos que a pessoa só possa apagar LandingHosts de Inboxes que pertencem a ela # Garantimos que a pessoa só possa acessar/apagar LandingHosts de Inboxes que pertencem a ela
valid_inbox_ids = Current.account.inboxes.pluck(:id) valid_inbox_ids = Current.account.inboxes.pluck(:id)
@landing_host = LandingHost.where(inbox_id: valid_inbox_ids).find(params[:id]) @landing_host = LandingHost.where(inbox_id: valid_inbox_ids).find(params[:id])
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
@ -39,6 +47,11 @@ class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseControl
end end
def landing_host_params def landing_host_params
params.require(:landing_host).permit(:hostname, :unit_code, :active, :auto_label) params.require(:landing_host).permit(
:hostname, :unit_code, :active, :auto_label,
:page_title, :page_subtitle, :button_text, :logo_url,
:suite_image_url, :theme_color, :whatsapp_number,
:initial_message, :default_source, :default_campanha
)
end end
end end

View File

@ -1,5 +1,23 @@
class Public::LandingPagesController < PublicController class Public::LandingPagesController < PublicController
layout false layout false
def show; end def show
host = request.host.to_s.sub(/^www\./, '')
@landing_host = LandingHost.find_by(hostname: host, active: true)
# Fallback local para testes
return unless Rails.env.development? && @landing_host.nil?
@landing_host = LandingHost.first || LandingHost.new(
page_title: 'Atendimento Express',
page_subtitle: 'Clique e fale direto com a recepcao agora',
whatsapp_number: '556136131003',
initial_message: 'Ola! Tenho interesse.',
theme_color: '#27c15b',
logo_url: 'https://iachat.hoteis1001noites.com.br/assets/images/dashboard/captain/logo.svg',
unit_code: 'express',
default_source: 'direto',
default_campanha: 'site'
)
end
end end

View File

@ -13,6 +13,12 @@ export default {
{ landing_host: data } { landing_host: data }
); );
}, },
updateHost(accountId, inboxId, id, data) {
return axios.patch(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`,
{ landing_host: data }
);
},
deleteHost(accountId, inboxId, id) { deleteHost(accountId, inboxId, id) {
return axios.delete( return axios.delete(
`/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}` `/api/v1/accounts/${accountId}/inboxes/${inboxId}/landing_hosts/${id}`

View File

@ -234,7 +234,8 @@
"Ações" "Ações"
], ],
"ADD_NEW_ITEM": "Adicione fotos na galeria", "ADD_NEW_ITEM": "Adicione fotos na galeria",
"NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes." "NO_ITEMS_MESSAGE": "Ainda não há fotos cadastradas para envio automático aos clientes.",
"VIEW_URL": "URL da Imagem"
}, },
"DELETE": { "DELETE": {
"CONFIRM": { "CONFIRM": {
@ -274,14 +275,14 @@
"SPECIFIC_HELP": "Essas fotos serão usadas somente na caixa de entrada {inbox}." "SPECIFIC_HELP": "Essas fotos serão usadas somente na caixa de entrada {inbox}."
}, },
"SUITE_CATEGORY": { "SUITE_CATEGORY": {
"LABEL": "Categoria da suíte", "LABEL": "Categoria",
"PLACEHOLDER": "Ex: Hidromassagem", "PLACEHOLDER": "Ex: Hidromassagem",
"ERROR": "A categoria é obrigatória" "ERROR": "A categoria é obrigatória"
}, },
"SUITE_NUMBER": { "SUITE_NUMBER": {
"LABEL": "Número/identificador da suíte", "LABEL": "Nome/identificador",
"PLACEHOLDER": "Ex: 101", "PLACEHOLDER": "Ex: 101",
"ERROR": "O identificador da suíte é obrigatório" "ERROR": "O identificador é obrigatório"
}, },
"DESCRIPTION": { "DESCRIPTION": {
"LABEL": "Descrição da foto", "LABEL": "Descrição da foto",
@ -337,7 +338,7 @@
"SELECT_INBOX_HINT": "Clique em uma caixa de entrada acima para ver e configurar os templates.", "SELECT_INBOX_HINT": "Clique em uma caixa de entrada acima para ver e configurar os templates.",
"EMPTY": { "EMPTY": {
"TITLE": "Nenhum template configurado", "TITLE": "Nenhum template configurado",
"DESC": "Crie templates de mensagem automática para esta caixa de entrada." "DESC": "Configure as permissões das informações que o sistema utiliza. Por ex.: Quais imagens enviar durante as aproximações."
} }
} }
}, },

View File

@ -98,11 +98,6 @@ const confirmDelete = async () => {
> >
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[2]') }} {{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[2]') }}
</th> </th>
<th
class="py-3 pr-4 text-left text-xs font-medium uppercase tracking-wider text-n-slate-10"
>
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.TABLE_HEADER[3]') }}
</th>
<th <th
class="py-3 text-right text-xs font-medium uppercase tracking-wider text-n-slate-10" class="py-3 text-right text-xs font-medium uppercase tracking-wider text-n-slate-10"
> >
@ -120,6 +115,14 @@ const confirmDelete = async () => {
class="h-16 w-24 rounded object-cover" class="h-16 w-24 rounded object-cover"
/> />
</td> </td>
<td class="py-4 pr-4">
<p class="mb-0 font-medium text-n-slate-12">
{{ item.suite_category }}
</p>
<p class="mb-0 text-xs text-n-slate-10">
{{ item.suite_number }}
</p>
</td>
<td class="py-4 pr-4"> <td class="py-4 pr-4">
<p class="mb-0 font-medium text-n-slate-12"> <p class="mb-0 font-medium text-n-slate-12">
{{ {{
@ -131,13 +134,19 @@ const confirmDelete = async () => {
<p class="mb-0 text-xs text-n-slate-10"> <p class="mb-0 text-xs text-n-slate-10">
{{ item.description }} {{ item.description }}
</p> </p>
<div v-if="item.image_url" class="mt-1 flex items-center">
<a
:href="item.image_url"
target="_blank"
rel="noopener noreferrer"
class="flex items-center gap-1 break-all text-[11px] text-n-brand-9 hover:underline"
>
<i class="i-lucide-external-link size-3" />
{{ t('CAPTAIN_SETTINGS.GALLERY.LIST.VIEW_URL') }}
</a>
</div>
</td> </td>
<td class="py-4 pr-4 text-n-slate-11">
{{ item.suite_category }}
</td>
<td class="py-4 pr-4 text-n-slate-11">
{{ item.suite_number }}
</td>
<td class="py-4"> <td class="py-4">
<div class="flex justify-end gap-2"> <div class="flex justify-end gap-2">
<Button <Button

View File

@ -22,19 +22,23 @@ export default {
newAutoLabel: '', newAutoLabel: '',
isLoading: false, isLoading: false,
isSaving: false, isSaving: false,
isUpdating: false,
expandedHostId: null,
editingHostData: {},
labels: { labels: {
title: 'Landing Pages (Tracking de Origem)', title: 'Landing Pages (Tracking de Origem)',
subtitle: subtitle:
'Defina os domínios de Landing Pages que enviam leads para esta caixa de entrada. O sistema usará esses domínios para identificar automaticamente a origem de cada conversa.', 'Defina os domínios de Landing Pages para esta caixa de entrada e personalize a aparência de cada um.',
loading: 'Carregando...', loading: 'Carregando...',
empty: 'Nenhum domínio cadastrado ainda.', empty: 'Nenhum domínio cadastrado ainda.',
colHostname: 'Hostname', colHostname: 'Hostname',
colCode: 'Código / Unidade', colCode: 'Código / Unidade',
colLabel: 'Etiqueta automática', colLabel: 'Etiqueta',
colPublicLink: 'Página pública', colPublicLink: 'Link',
remove: 'Remover', remove: 'Remover',
edit: 'Editar',
open: 'Abrir', open: 'Abrir',
copy: 'Copiar link', copy: 'Copiar',
addTitle: 'Adicionar Domínio', addTitle: 'Adicionar Domínio',
labelHostname: 'Hostname *', labelHostname: 'Hostname *',
placeholderHostname: 'express.seuhotel.com.br', placeholderHostname: 'express.seuhotel.com.br',
@ -53,6 +57,22 @@ export default {
successDel: 'Domínio removido.', successDel: 'Domínio removido.',
successCopy: 'Link copiado!', successCopy: 'Link copiado!',
errCopy: 'Não foi possível copiar o link.', errCopy: 'Não foi possível copiar o link.',
// Novos campos
cfgTitle: 'Editar Configurações de Aparência e Tracking',
cfgPageTitle: 'Título Principal',
cfgPageSubtitle: 'Subtítulo',
cfgButtonText: 'Texto do Botão',
cfgThemeColor: 'Cor do Botão (Ex: #25D366)',
cfgLogoUrl: 'URL da Logo',
cfgWhatsapp: 'Número de WhatsApp (5511999999999)',
cfgMessage: 'Mensagem Inicial',
cfgSource: 'Origem (UTM Source) Padrão',
cfgCampaign: 'Campanha (UTM Campaign) Padrão',
cfgSave: 'Salvar Alterações',
cfgCancel: 'Cancelar',
errUpdate: 'Erro ao salvar configurações.',
successUpdate: 'Configurações atualizadas com sucesso!',
}, },
}; };
}, },
@ -61,6 +81,9 @@ export default {
addLabel() { addLabel() {
return this.isSaving ? this.labels.labelSaving : this.labels.labelAdd; return this.isSaving ? this.labels.labelSaving : this.labels.labelAdd;
}, },
updateLabel() {
return this.isUpdating ? this.labels.labelSaving : this.labels.cfgSave;
},
}, },
mounted() { mounted() {
this.fetchHosts(); this.fetchHosts();
@ -84,7 +107,6 @@ export default {
if (!this.newHostname.trim()) return; if (!this.newHostname.trim()) return;
this.isSaving = true; this.isSaving = true;
// Sanitiza: remove protocolo, www, barras e espaços
const cleanHostname = this.newHostname const cleanHostname = this.newHostname
.trim() .trim()
.toLowerCase() .toLowerCase()
@ -114,6 +136,14 @@ export default {
} }
}, },
async deleteHost(id) { async deleteHost(id) {
if (
// eslint-disable-next-line no-alert
!window.confirm(
'Deseja realmente remover este domínio? A landing page parará de funcionar imediatamente.'
)
) {
return;
}
try { try {
await landingHostsApi.deleteHost( await landingHostsApi.deleteHost(
this.currentAccountId, this.currentAccountId,
@ -121,11 +151,43 @@ export default {
id id
); );
this.landingHosts = this.landingHosts.filter(h => h.id !== id); this.landingHosts = this.landingHosts.filter(h => h.id !== id);
this.expandedHostId = null;
useAlert(this.labels.successDel); useAlert(this.labels.successDel);
} catch { } catch {
useAlert(this.labels.errDel); useAlert(this.labels.errDel);
} }
}, },
openEdit(host) {
this.expandedHostId = host.id;
this.editingHostData = { ...host };
},
cancelEdit() {
this.expandedHostId = null;
},
async saveEdit() {
this.isUpdating = true;
try {
const { data } = await landingHostsApi.updateHost(
this.currentAccountId,
this.inbox.id,
this.expandedHostId,
this.editingHostData
);
// Atualiza na lista
const index = this.landingHosts.findIndex(h => h.id === data.id);
if (index !== -1) {
this.landingHosts.splice(index, 1, data);
}
this.expandedHostId = null;
useAlert(this.labels.successUpdate);
} catch {
useAlert(this.labels.errUpdate);
} finally {
this.isUpdating = false;
}
},
landingUrl(hostname) { landingUrl(hostname) {
return `https://${hostname}/lp`; return `https://${hostname}/lp`;
}, },
@ -184,12 +246,8 @@ export default {
<th class="px-4 py-3 text-right" /> <th class="px-4 py-3 text-right" />
</tr> </tr>
</thead> </thead>
<tbody> <tbody v-for="host in landingHosts" :key="host.id">
<tr <tr class="border-t border-n-slate-3 hover:bg-n-slate-1">
v-for="host in landingHosts"
:key="host.id"
class="border-t border-n-slate-3 hover:bg-n-slate-1"
>
<td class="px-4 py-3 font-mono text-n-slate-12 text-xs"> <td class="px-4 py-3 font-mono text-n-slate-12 text-xs">
{{ host.hostname }} {{ host.hostname }}
</td> </td>
@ -218,19 +276,199 @@ export default {
</div> </div>
</td> </td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<button <div class="flex items-center justify-end gap-3">
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors" <button
@click="deleteHost(host.id)" class="text-xs text-n-brand hover:underline font-medium transition-colors"
@click="openEdit(host)"
>
{{ labels.edit }}
</button>
<button
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors"
@click="deleteHost(host.id)"
>
{{ labels.remove }}
</button>
</div>
</td>
</tr>
<!-- Formulário de Edição Inline -->
<tr v-if="expandedHostId === host.id" class="bg-n-brand-1/30">
<td colspan="5" class="p-4 border-t border-n-brand-3">
<div
class="bg-white dark:bg-slate-900 border border-n-brand-3 rounded-lg p-5 shadow-sm"
> >
{{ labels.remove }} <h4 class="text-sm font-semibold text-n-slate-12 mb-4">
</button> {{ labels.cfgTitle }}
</h4>
<div
class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mb-4"
>
<!-- Básico -->
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.labelHostname }}</label
>
<woot-input
v-model="editingHostData.hostname"
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.labelCode }}</label
>
<woot-input
v-model="editingHostData.unit_code"
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.labelAutoLabel }}</label
>
<woot-input
v-model="editingHostData.auto_label"
class="[&>input]:!mb-0"
/>
</div>
<!-- Textos LP -->
<div class="sm:col-span-2">
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgPageTitle }}</label
>
<woot-input
v-model="editingHostData.page_title"
class="[&>input]:!mb-0"
/>
</div>
<div class="sm:col-span-3">
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgPageSubtitle }}</label
>
<woot-input
v-model="editingHostData.page_subtitle"
class="[&>input]:!mb-0"
/>
</div>
<!-- Botão e Aparência -->
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgButtonText }}</label
>
<woot-input
v-model="editingHostData.button_text"
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgThemeColor }}</label
>
<div class="flex gap-2">
<input
v-model="editingHostData.theme_color"
type="color"
class="h-9 w-10 p-1 border border-n-slate-3 rounded cursor-pointer"
/>
<woot-input
v-model="editingHostData.theme_color"
class="flex-1 [&>input]:!mb-0"
/>
</div>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgLogoUrl }}</label
>
<woot-input
v-model="editingHostData.logo_url"
class="[&>input]:!mb-0"
/>
</div>
<!-- WhatsApp e Tracking -->
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgWhatsapp }}</label
>
<woot-input
v-model="editingHostData.whatsapp_number"
type="tel"
placeholder="5511999999999"
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.cfgMessage }}</label
>
<woot-input
v-model="editingHostData.initial_message"
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgSource }}</label
>
<woot-input
v-model="editingHostData.default_source"
placeholder="direto"
class="[&>input]:!mb-0"
/>
</div>
<div>
<label
class="block text-xs font-medium text-n-slate-11 mb-1"
>{{ labels.cfgCampaign }}</label
>
<woot-input
v-model="editingHostData.default_campanha"
placeholder="site"
class="[&>input]:!mb-0"
/>
</div>
</div>
<div
class="mt-4 flex justify-end gap-3 border-t border-n-slate-2 pt-4"
>
<NextButton
:label="labels.cfgCancel"
variant="hollow"
@click="cancelEdit"
/>
<NextButton
:label="updateLabel"
:disabled="isUpdating"
@click="saveEdit"
/>
</div>
</div>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </div>
<!-- Formulário de adição --> <!-- Formulário de adição rápida (somente hostname e unit_code) -->
<div class="border border-n-slate-3 rounded-lg p-4 bg-n-slate-1"> <div class="border border-n-slate-3 rounded-lg p-4 bg-n-slate-1">
<h3 class="text-sm font-semibold text-n-slate-12 mb-3"> <h3 class="text-sm font-semibold text-n-slate-12 mb-3">
{{ labels.addTitle }} {{ labels.addTitle }}

View File

@ -2,14 +2,25 @@
# #
# Table name: landing_hosts # Table name: landing_hosts
# #
# id :bigint not null, primary key # id :bigint not null, primary key
# active :boolean # active :boolean
# auto_label :string # auto_label :string
# hostname :string # button_text :string default("Ver disponibilidade agora")
# unit_code :string # custom_config :jsonb
# created_at :datetime not null # default_campanha :string
# updated_at :datetime not null # default_source :string
# inbox_id :integer # hostname :string
# initial_message :text
# logo_url :string
# page_subtitle :string default("Atendimento Imediato\nEntrada Discreta\nSem Burocracia")
# page_title :string default("Atendimento Express")
# suite_image_url :string
# theme_color :string default("#25D366")
# unit_code :string
# whatsapp_number :string default("")
# created_at :datetime not null
# updated_at :datetime not null
# inbox_id :integer
# #
# Indexes # Indexes
# #

View File

@ -12,6 +12,7 @@
# user_agent :string # user_agent :string
# created_at :datetime not null # created_at :datetime not null
# updated_at :datetime not null # updated_at :datetime not null
# click_id :string
# contact_id :integer # contact_id :integer
# conversation_id :integer # conversation_id :integer
# inbox_id :integer # inbox_id :integer

View File

@ -3,7 +3,8 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Atendimento WhatsApp</title> <title><%= @landing_host&.page_title.presence || 'Atendimento WhatsApp' %></title>
<link id="pageFavicon" rel="icon" type="image/png" href="<%= @landing_host&.logo_url || 'https://iachat.hoteis1001noites.com.br/assets/images/dashboard/captain/logo.svg' %>" />
<style> <style>
:root { :root {
--bg-1: #040b18; --bg-1: #040b18;
@ -12,7 +13,7 @@
--card-border: #1f2c43; --card-border: #1f2c43;
--text-1: #e7ecf6; --text-1: #e7ecf6;
--text-2: #96a2b5; --text-2: #96a2b5;
--btn: #27c15b; --btn: <%= @landing_host&.theme_color.presence || '#27c15b' %>;
--btn-text: #f4fff7; --btn-text: #f4fff7;
} }
@ -56,8 +57,6 @@
display: grid; display: grid;
place-items: center; place-items: center;
background: rgba(40, 215, 122, 0.16); background: rgba(40, 215, 122, 0.16);
cursor: pointer;
user-select: none;
} }
.logo-wrap img { .logo-wrap img {
@ -107,330 +106,91 @@
font-size: 14px; font-size: 14px;
color: #647086; color: #647086;
} }
.admin-overlay {
position: fixed;
inset: 0;
background: rgba(4, 10, 22, 0.78);
display: none;
align-items: center;
justify-content: center;
padding: 18px;
z-index: 9999;
}
.admin-overlay.open {
display: flex;
}
.admin-modal {
width: min(100%, 560px);
max-height: 94vh;
overflow: auto;
border-radius: 16px;
border: 1px solid var(--card-border);
background: #10192d;
padding: 18px;
}
.admin-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
.admin-head h2 {
margin: 0;
}
.close-btn {
border: 0;
border-radius: 10px;
width: 36px;
height: 36px;
font-size: 20px;
cursor: pointer;
background: #1a2840;
color: #c8d3e6;
}
.field {
margin: 8px 0;
}
.field label {
display: block;
font-size: 13px;
color: #8fa1bd;
margin-bottom: 4px;
}
.field input,
.field textarea {
width: 100%;
border: 1px solid #2a3b58;
border-radius: 10px;
background: #182338;
color: #e4ecf8;
padding: 10px 11px;
font-size: 14px;
}
.field textarea {
min-height: 70px;
resize: vertical;
}
.preview {
margin-top: 10px;
background: #111b2d;
border: 1px solid #253750;
border-radius: 10px;
padding: 10px;
font-size: 13px;
color: #c8d3e6;
word-break: break-word;
}
.actions {
margin-top: 12px;
display: flex;
gap: 10px;
}
.actions button {
flex: 1;
border: 0;
border-radius: 12px;
padding: 11px 14px;
font-size: 14px;
font-weight: 700;
cursor: pointer;
}
.save-btn {
background: #27c15b;
color: #f4fff7;
}
.reset-btn {
background: #22324e;
color: #d1daea;
}
</style> </style>
</head> </head>
<body> <body>
<div class="page"> <% if @landing_host.nil? %>
<div class="card"> <div class="page">
<div id="logoTapArea" class="logo-wrap" title="logo"> <div class="card">
<img id="logoImage" alt="logo" /> <h1>Página não encontrada</h1>
</div> <p class="subtitle">Verifique a URL digitada e tente novamente.</p>
<h1 id="titleText"></h1> </div>
<p id="subtitleText" class="subtitle"></p> </div>
<button id="whatsButton" class="wa-button" type="button">Falar no WhatsApp</button> <% else %>
<div class="foot">Pagina segura · atendimento humano</div> <div class="page">
</div> <div class="card">
</div> <div class="logo-wrap" title="logo">
<img src="<%= @landing_host.logo_url.presence || 'https://iachat.hoteis1001noites.com.br/assets/images/dashboard/captain/logo.svg' %>" alt="logo" />
<div id="adminOverlay" class="admin-overlay"> </div>
<div class="admin-modal"> <h1><%= @landing_host.page_title %></h1>
<div class="admin-head"> <p class="subtitle"><%= @landing_host.page_subtitle&.gsub("\n", "<br>")&.html_safe %></p>
<h2>Painel Admin</h2> <button id="whatsButton" class="wa-button" type="button"><%= @landing_host.button_text.presence || 'Falar no WhatsApp' %></button>
<button id="closeAdmin" class="close-btn" type="button">×</button> <div class="foot">Pagina segura · atendimento humano</div>
</div>
<div class="field"><label>Nome da unidade</label><input id="f_unit_name" /></div>
<div class="field"><label>Titulo</label><input id="f_title" /></div>
<div class="field"><label>Subtitulo</label><input id="f_subtitle" /></div>
<div class="field"><label>Telefone WhatsApp</label><input id="f_phone" /></div>
<div class="field"><label>Mensagem inicial (WhatsApp)</label><textarea id="f_message"></textarea></div>
<div class="field"><label>Source</label><input id="f_source" /></div>
<div class="field"><label>Campanha</label><input id="f_campanha" /></div>
<div class="field"><label>Unidade (tag)</label><input id="f_unidade" /></div>
<div class="field"><label>Inbox (tag)</label><input id="f_inbox" /></div>
<div class="field"><label>Cor do botao</label><input id="f_button_color" type="color" /></div>
<div class="field"><label>Logo (URL ou base64)</label><input id="f_logo_url" /></div>
<div class="field"><label>Enviar logo (arquivo)</label><input id="f_logo_file" type="file" accept="image/*" /></div>
<div class="preview">
<strong>Mensagem que sera enviada:</strong>
<div id="messagePreview"></div>
</div>
<div class="actions">
<button id="saveAdmin" class="save-btn" type="button">Salvar</button>
<button id="resetAdmin" class="reset-btn" type="button">Resetar</button>
</div> </div>
</div> </div>
</div>
<script> <script>
(function () { (function () {
const defaults = { const config = {
unit_name: "Hotel", hostname: "<%= j @landing_host.hostname %>",
title: "Atendimento imediato no WhatsApp", phone: "<%= j @landing_host.whatsapp_number.to_s.gsub(/[^\d]/, '') %>",
subtitle: "Clique e fale direto com a recepcao agora", message: "<%= j @landing_host.initial_message.to_s %>",
phone: "556136131003", defaultSource: "<%= j @landing_host.default_source.to_s %>",
message: "Ola! Tenho interesse.", defaultCampanha: "<%= j @landing_host.default_campanha.to_s %>",
source: "direto",
campanha: "site",
unidade: "express",
inbox: "EXPRESS",
button_color: "#27c15b",
logo_url: "https://iachat.hoteis1001noites.com.br/assets/images/dashboard/captain/logo.svg",
};
const params = new URLSearchParams(window.location.search);
const storageKey = "lp_config_" + window.location.hostname;
const clickKey = "lp_click_id_" + window.location.hostname;
const toConfig = (raw) => {
try {
return { ...defaults, ...(JSON.parse(raw || "{}")) };
} catch (_) {
return { ...defaults };
}
};
let config = toConfig(localStorage.getItem(storageKey));
const $ = (id) => document.getElementById(id);
const inputs = {
unit_name: $("f_unit_name"),
title: $("f_title"),
subtitle: $("f_subtitle"),
phone: $("f_phone"),
message: $("f_message"),
source: $("f_source"),
campanha: $("f_campanha"),
unidade: $("f_unidade"),
inbox: $("f_inbox"),
button_color: $("f_button_color"),
logo_url: $("f_logo_url"),
};
function getClickId() {
const fromUrl = params.get("click_id") || params.get("clickid") || params.get("utm_id") || params.get("gclid");
if (fromUrl) {
localStorage.setItem(clickKey, fromUrl);
return fromUrl;
}
const existing = localStorage.getItem(clickKey);
if (existing) return existing;
const generated = `lp-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
localStorage.setItem(clickKey, generated);
return generated;
}
function currentSource() {
return params.get("utm_source") || params.get("source") || config.source || "direto";
}
function currentCampanha() {
return params.get("utm_campaign") || params.get("campanha") || config.campanha || "site";
}
function whatsappText() {
return config.message || "";
}
function syncView() {
$("titleText").textContent = config.title;
$("subtitleText").textContent = config.subtitle;
$("logoImage").src = config.logo_url;
$("whatsButton").style.background = config.button_color || "#27c15b";
$("messagePreview").textContent = whatsappText();
Object.keys(inputs).forEach((key) => {
if (inputs[key]) inputs[key].value = config[key] || "";
});
}
async function sendTrack() {
const payload = {
hostname: window.location.hostname,
lp: window.location.href,
click_id: getClickId(),
source: currentSource(),
campanha: currentCampanha(),
}; };
try { const params = new URLSearchParams(window.location.search);
await fetch("/track/click", { const clickKey = "lp_click_id_" + window.location.hostname;
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
keepalive: true,
});
} catch (_) {}
}
async function openWhatsapp() { function getClickId() {
await sendTrack(); const fromUrl = params.get("click_id") || params.get("clickid") || params.get("utm_id") || params.get("gclid");
const phone = (config.phone || "").replace(/[^\d]/g, ""); if (fromUrl) {
const text = encodeURIComponent(whatsappText()); localStorage.setItem(clickKey, fromUrl);
window.location.href = `https://wa.me/${phone}?text=${text}`; return fromUrl;
} }
const existing = localStorage.getItem(clickKey);
let logoTapCount = 0; if (existing) return existing;
let logoTapTimer = null; const generated = `lp-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
localStorage.setItem(clickKey, generated);
$("logoTapArea").addEventListener("click", () => { return generated;
logoTapCount += 1;
clearTimeout(logoTapTimer);
logoTapTimer = setTimeout(() => {
logoTapCount = 0;
}, 1500);
if (logoTapCount >= 5) {
logoTapCount = 0;
$("adminOverlay").classList.add("open");
} }
});
$("closeAdmin").addEventListener("click", () => { function currentSource() {
$("adminOverlay").classList.remove("open"); return params.get("utm_source") || params.get("source") || config.defaultSource || "direto";
}); }
$("adminOverlay").addEventListener("click", (e) => { function currentCampanha() {
if (e.target === $("adminOverlay")) $("adminOverlay").classList.remove("open"); return params.get("utm_campaign") || params.get("campanha") || config.defaultCampanha || "site";
}); }
Object.keys(inputs).forEach((key) => { async function sendTrack() {
if (!inputs[key]) return; const payload = {
inputs[key].addEventListener("input", () => { hostname: config.hostname || window.location.hostname,
config[key] = inputs[key].value; lp: window.location.href,
syncView(); click_id: getClickId(),
}); source: currentSource(),
}); campanha: currentCampanha(),
};
$("f_logo_file").addEventListener("change", (e) => { try {
const file = e.target.files && e.target.files[0]; await fetch("/track/click", {
if (!file) return; method: "POST",
const reader = new FileReader(); headers: { "Content-Type": "application/json" },
reader.onload = () => { body: JSON.stringify(payload),
config.logo_url = String(reader.result || ""); keepalive: true,
syncView(); });
}; } catch (_) {}
reader.readAsDataURL(file); }
});
$("saveAdmin").addEventListener("click", () => { async function openWhatsapp() {
localStorage.setItem(storageKey, JSON.stringify(config)); await sendTrack();
$("adminOverlay").classList.remove("open"); const text = encodeURIComponent(config.message);
}); window.location.href = `https://wa.me/${config.phone}?text=${text}`;
}
$("resetAdmin").addEventListener("click", () => { document.getElementById("whatsButton").addEventListener("click", openWhatsapp);
localStorage.removeItem(storageKey); })();
config = { ...defaults }; </script>
syncView(); <% end %>
});
$("whatsButton").addEventListener("click", openWhatsapp);
syncView();
})();
</script>
</body> </body>
</html> </html>

View File

@ -237,7 +237,7 @@ Rails.application.routes.draw do
get :campaigns, on: :member get :campaigns, on: :member
get :agent_bot, on: :member get :agent_bot, on: :member
post :set_agent_bot, on: :member post :set_agent_bot, on: :member
resources :landing_hosts, only: [:index, :create, :destroy] resources :landing_hosts, only: [:index, :create, :update, :destroy]
post :setup_channel_provider, on: :member post :setup_channel_provider, on: :member
post :disconnect_channel_provider, on: :member post :disconnect_channel_provider, on: :member
delete :avatar, on: :member delete :avatar, on: :member

View File

@ -0,0 +1,4 @@
class AddVisualConfigToLandingHosts < ActiveRecord::Migration[7.1]
def change
end
end

View File

@ -0,0 +1,8 @@
class AddConfigToLandingHosts < ActiveRecord::Migration[7.0]
def change
add_column :landing_hosts, :initial_message, :text
add_column :landing_hosts, :default_source, :string
add_column :landing_hosts, :default_campanha, :string
add_column :landing_hosts, :custom_config, :jsonb, default: {}
end
end

View File

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_03_02_211000) do ActiveRecord::Schema[7.1].define(version: 2026_03_03_074000) do
# These extensions should be enabled to support this database # These extensions should be enabled to support this database
enable_extension "pg_stat_statements" enable_extension "pg_stat_statements"
enable_extension "pg_trgm" enable_extension "pg_trgm"
@ -1561,6 +1561,17 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_02_211000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "auto_label" t.string "auto_label"
t.string "page_title", default: "Atendimento Express"
t.string "page_subtitle", default: "Atendimento Imediato\nEntrada Discreta\nSem Burocracia"
t.string "button_text", default: "Ver disponibilidade agora"
t.string "logo_url"
t.string "suite_image_url"
t.string "theme_color", default: "#25D366"
t.string "whatsapp_number", default: ""
t.text "initial_message"
t.string "default_source"
t.string "default_campanha"
t.jsonb "custom_config", default: {}
t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true
end end

View File

@ -6,10 +6,26 @@ Garantir que os cliques na landing page sejam rastreados, capturando UTMs (orige
### Contexto ### Contexto
Atualmente, o `TrackingController` e o `LeadClick` capturam o hostname e o IP, mas não estão salvando o `click_id` (enviado pelo frontend) nem extraindo parâmetros UTM da URL da landing page. Atualmente, o `TrackingController` e o `LeadClick` capturam o hostname e o IP, mas não estão salvando o `click_id` (enviado pelo frontend) nem extraindo parâmetros UTM da URL da landing page.
### Como testar:
Na tela de chat, conferir se os Atributos `lp`, `campanha`, `origem` e `click_id` estão sendo preenchidos para novos contatos do WhatsApp criados através de LPs.
## Parte 2 - Multiunidade (Server-side LPs)
- **Problema:** As Landing Pages dependiam do `localStorage` do navegador para manter configurações (nº de telefone, titulo, etc). Isso tornava impossível rastreamento persistente (quando o usuário trocava de celular) e não suportava estrutura multiunidade profissional.
- **Implementação:**
1. Feita migração adicionando à tabela `landing_hosts` os campos faltantes: `initial_message`, `default_source`, `default_campanha`, `custom_config`.
2. Atualizamos `LandingPagesController` (Controller Backend) para carregar os dados baseando-se no `request.host`.
3. Modificamos o HTML de exibição principal (`app/views/public/landing_pages/show.html.erb`), removendo painel de administração overlay e substituindo por ERB para renderização Server-Side (SSG).
## Em Andamento / Concluídos Recentes
1. **Backend**: Atualizar o `TrackingController` para salvar `click_id` e extrair UTMs caso não sejam enviados explicitamente.
2. Validar se o `click_id` está sendo recebido no Controller corretamente (testado via postman/local e aprovado - os testes do Sidekiq estão rodando no log).
3. Criar painel no Dashboard para gerenciar `landing_hosts` (migrando configurações do front-end armazenadas no LocalStorage para Back-end em estrutura multi-unidade) -> **Concluído:** Criada interface em Inboxes > Configurações > Landing Pages.
### Próximos Passos ### Próximos Passos
1. **Migração**: Adicionar o campo `click_id` na tabela `lead_clicks`. 1. **Migração**: Adicionar o campo `click_id` na tabela `lead_clicks`.
2. **Backend**: Atualizar o `TrackingController` para salvar `click_id` e extrair UTMs caso não sejam enviados explicitamente.
3. **Frontend**: Sugerir script JS para a landing page que capture UTMs da URL e as envie para o Supabase. 3. **Frontend**: Sugerir script JS para a landing page que capture UTMs da URL e as envie para o Supabase.
- Adicionar interface "Painel Admin" para Gerenciar Landing Pages dentro das configurações originais do Chatwoot (Vue Frontend).
- Conferir os Logs de tracking e Sidekiq em mensagens locais para debugar qualquer problema residual no `AttributionMatcherService`.
### Como Validar ### Como Validar
Simular um clique com UTMs: Simular um clique com UTMs:

View File

20
test_landing_page.sh Executable file
View File

@ -0,0 +1,20 @@
#!/bin/bash
# Script de teste rápido da Landing Page simulando tráfego
# Defina a URL base local, se o porto for 3000
BASE_URL="http://localhost:3000/lp"
# Imprime o título de teste e a URL para clicar
echo "---------------------------------------------------------"
echo "Teste a sua Landing Page abrindo este link no navegador:"
echo ""
echo " 👉 $BASE_URL"
echo ""
echo "Teste também com UTMs simulando um clique de Anúncio:"
echo ""
echo " 👉 $BASE_URL?utm_source=meta&utm_medium=cpc&utm_campaign=black_friday"
echo ""
echo "Abra os links acima. Eles buscarão as configurações salvas"
echo "pelo seu painel do Chatwoot (se houver configuração"
echo "para 'localhost', ela aparecerá)."
echo "---------------------------------------------------------"