feat: add landing click attribution tracking and stats endpoint

This commit is contained in:
Rodrigo Borba 2026-03-02 17:37:28 -03:00
parent a73689dce4
commit 98252e968a
9 changed files with 372 additions and 5 deletions

View File

@ -0,0 +1,37 @@
class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController
def index
clicks = account_clicks
total = clicks.count
convs = clicks.where.not(conversation_id: nil).count
render json: {
total_clicks: total,
total_conversions: convs,
conversion_rate: total.positive? ? (convs.to_f / total * 100).round(1) : 0,
by_source: group_by(clicks, :source),
by_campaign: group_by(clicks, :campanha),
by_hostname: group_by(clicks, :hostname)
}
end
private
def account_clicks
LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id })
end
def group_by(clicks, column)
clicks
.group(column)
.select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions")
.map do |r|
{
label: r.public_send(column).presence || '(sem nome)',
clicks: r.clicks,
conversions: r.conversions,
rate: r.clicks.positive? ? (r.conversions.to_f / r.clicks * 100).round(1) : 0
}
end
.sort_by { |r| -r[:clicks] }
end
end

View File

@ -10,11 +10,12 @@ class Api::V1::TrackingController < ActionController::API
private
def resolved_inbox_id
LandingHost.find_by(hostname: params[:hostname].to_s.strip, active: true)&.inbox_id
host = params[:hostname].to_s.strip.sub(%r{^https?://}, '')
LandingHost.find_by(hostname: host, active: true)&.inbox_id
end
def click_params
{
base_params = {
inbox_id: resolved_inbox_id,
ip: params[:ip].presence || request.remote_ip,
user_agent: request.user_agent || params[:user_agent],
@ -22,7 +23,22 @@ class Api::V1::TrackingController < ActionController::API
source: params[:source],
campanha: params[:campanha],
lp: params[:lp],
click_id: params[:click_id],
status: :clicked
}
# Se 'lp' for fornecido, extraímos os UTMs se fonte ou campanha estiverem vazios
if base_params[:lp].present?
begin
uri = URI.parse(base_params[:lp])
query = Rack::Utils.parse_nested_query(uri.query)
base_params[:source] ||= query['utm_source']
base_params[:campanha] ||= query['utm_campaign']
rescue StandardError => e
Rails.logger.warn("Error parsing LP URL for UTMs: #{e.message}")
end
end
base_params
end
end

View File

@ -394,7 +394,23 @@
"TABS": {
"DASHBOARD": "Dashboard",
"INSIGHTS": "AI Insights",
"OPERATIONAL": "Operational"
"OPERATIONAL": "Operational",
"LANDING_PAGES": "Landing Pages"
},
"LP": {
"LOADING": "Loading data...",
"NO_DATA": "No clicks recorded yet. Integrate the pixel on your landing page to see data here.",
"TOTAL_CLICKS": "Total Clicks",
"TOTAL_CONVERSIONS": "Conversions (WhatsApp)",
"CONVERSION_RATE": "Conversion Rate",
"BY_SOURCE": "Clicks by Source",
"BY_CAMPAIGN": "Clicks by Campaign",
"BY_HOSTNAME": "Clicks by Landing Page",
"CLICKS": "clicks",
"CONV": "conv",
"REFRESH": "Refresh",
"LEGEND_CLICKS": "Clicks",
"LEGEND_CONVERSIONS": "Conversions"
},
"FILTER_DATE": {
"LABEL": "Period:",

View File

@ -395,7 +395,23 @@
"TABS": {
"DASHBOARD": "Dashboard",
"INSIGHTS": "Insights IA",
"OPERATIONAL": "Operacional"
"OPERATIONAL": "Operacional",
"LANDING_PAGES": "Landing Pages"
},
"LP": {
"LOADING": "Carregando dados...",
"NO_DATA": "Nenhum clique registrado ainda. Integre o pixel na landing page para ver os dados aqui.",
"TOTAL_CLICKS": "Total de Cliques",
"TOTAL_CONVERSIONS": "Conversões (WhatsApp)",
"CONVERSION_RATE": "Taxa de Conversão",
"BY_SOURCE": "Cliques por Origem",
"BY_CAMPAIGN": "Cliques por Campanha",
"BY_HOSTNAME": "Cliques por Landing Page",
"CLICKS": "cliques",
"CONV": "conv",
"REFRESH": "Atualizar",
"LEGEND_CLICKS": "Cliques",
"LEGEND_CONVERSIONS": "Conversões"
},
"FILTER_DATE": {
"LABEL": "Período:",

View File

@ -1,4 +1,5 @@
<script setup>
/* global axios */
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
@ -26,8 +27,30 @@ const tabs = [
{ key: 'dashboard' },
{ key: 'insights' },
{ key: 'operational' },
{ key: 'landing_pages' },
];
const lpStats = ref(null);
const lpLoading = ref(false);
const fetchLpStats = async () => {
const user = store.getters['auth/getCurrentUser'];
const accountId =
user?.account_id || window.location.pathname.match(/accounts\/(\d+)/)?.[1];
if (!accountId) return;
lpLoading.value = true;
try {
const { data } = await axios.get(
`/api/v1/accounts/${accountId}/lead_click_stats`
);
lpStats.value = data;
} catch {
// silent
} finally {
lpLoading.value = false;
}
};
const getPeriodDates = period => {
const end = new Date();
const start = new Date();
@ -106,6 +129,7 @@ onMounted(async () => {
await store.dispatch('captainAssistants/get');
await store.dispatch('captainReports/fetchInsights', {});
if (hasProcessingInsights.value) startPolling();
await fetchLpStats();
});
onUnmounted(() => {
@ -215,10 +239,21 @@ const tabLabel = key => {
dashboard: t('CAPTAIN_REPORTS.TABS.DASHBOARD'),
insights: t('CAPTAIN_REPORTS.TABS.INSIGHTS'),
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
landing_pages: 'Landing Pages',
};
return map[key] || key;
};
const lpMaxClicks = computed(() => {
if (!lpStats.value) return 1;
const all = [
...(lpStats.value.by_source || []),
...(lpStats.value.by_campaign || []),
...(lpStats.value.by_hostname || []),
];
return Math.max(...all.map(r => r.clicks), 1);
});
// FAQ Quick-Add
const quickAddFaq = reactive({
open: false,
@ -1386,6 +1421,226 @@ const maxHandoffCount = computed(() =>
</p>
</div>
</div>
<!-- Tab: Landing Pages -->
<div v-else-if="activeTab === 'landing_pages'">
<!-- carregando -->
<div
v-if="lpLoading"
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<span
class="i-lucide-loader-circle size-8 animate-spin text-n-brand"
/>
<p class="text-sm text-n-slate-9">
{{ t('CAPTAIN_REPORTS.LP.LOADING') }}
</p>
</div>
<!-- sem dados -->
<div
v-else-if="!lpStats"
class="flex flex-col items-center justify-center gap-4 py-20 text-center"
>
<div
class="flex size-16 items-center justify-center rounded-full bg-n-slate-2"
>
<span
class="i-lucide-mouse-pointer-click size-8 text-n-slate-9"
/>
</div>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_REPORTS.LP.NO_DATA') }}
</p>
</div>
<!-- dados -->
<div v-else class="space-y-6">
<!-- KPI Cards -->
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-slate-12">
{{ lpStats.total_clicks?.toLocaleString() ?? 0 }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.LP.TOTAL_CLICKS') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-teal-11">
{{ lpStats.total_conversions?.toLocaleString() ?? 0 }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.LP.TOTAL_CONVERSIONS') }}
</p>
</div>
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
<p class="text-2xl font-bold text-n-blue-11">
{{ lpStats.conversion_rate ?? 0 }}{{ '%' }}
</p>
<p class="mt-1 text-xs text-n-slate-9">
{{ t('CAPTAIN_REPORTS.LP.CONVERSION_RATE') }}
</p>
</div>
</div>
<!-- Gráficos -->
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
<!-- Por Fonte -->
<div
v-if="lpStats.by_source?.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.LP.BY_SOURCE') }}
</p>
<div class="space-y-3">
<div
v-for="row in lpStats.by_source"
:key="row.label"
class="space-y-1"
>
<div class="flex items-center justify-between text-xs">
<span class="font-medium text-n-slate-11">
{{ row.label }}
</span>
<span class="text-n-slate-8">
{{ row.clicks }}
{{ t('CAPTAIN_REPORTS.LP.CLICKS') }}
{{ '·' }}
{{ row.conversions }}
{{ t('CAPTAIN_REPORTS.LP.CONV') }}
{{ '·' }}
{{ row.rate }}{{ '%' }}
</span>
</div>
<div
class="flex h-2 overflow-hidden rounded-full bg-n-slate-3"
>
<div
class="bg-n-blue-8 transition-all"
:style="{
width:
Math.round((row.clicks / lpMaxClicks) * 100) + '%',
}"
/>
<div
class="bg-n-teal-7 transition-all"
:style="{
width:
Math.round((row.conversions / lpMaxClicks) * 100) +
'%',
}"
/>
</div>
</div>
</div>
<div class="mt-3 flex gap-4">
<span
class="flex items-center gap-1.5 text-xs text-n-blue-11"
>
<span class="size-2 rounded-full bg-n-blue-8" />
{{ t('CAPTAIN_REPORTS.LP.LEGEND_CLICKS') }}
</span>
<span
class="flex items-center gap-1.5 text-xs text-n-teal-11"
>
<span class="size-2 rounded-full bg-n-teal-7" />
{{ t('CAPTAIN_REPORTS.LP.LEGEND_CONVERSIONS') }}
</span>
</div>
</div>
<!-- Por Campanha -->
<div
v-if="lpStats.by_campaign?.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.LP.BY_CAMPAIGN') }}
</p>
<div class="space-y-3">
<div
v-for="row in lpStats.by_campaign"
:key="row.label"
class="space-y-1"
>
<div class="flex items-center justify-between text-xs">
<span
class="font-medium text-n-slate-11 truncate max-w-[12rem]"
>
{{ row.label }}
</span>
<span class="shrink-0 text-n-slate-8">
{{ row.clicks }} {{ '·' }} {{ row.rate }}{{ '%' }}
</span>
</div>
<div class="h-2 overflow-hidden rounded-full bg-n-slate-3">
<div
class="h-2 bg-n-amber-8 transition-all"
:style="{
width:
Math.round((row.clicks / lpMaxClicks) * 100) + '%',
}"
/>
</div>
</div>
</div>
</div>
</div>
<!-- Por Hostname -->
<div
v-if="lpStats.by_hostname?.length"
class="rounded-2xl border border-n-weak bg-n-alpha-1 p-5"
>
<p
class="mb-4 text-xs font-semibold uppercase tracking-wide text-n-slate-9"
>
{{ t('CAPTAIN_REPORTS.LP.BY_HOSTNAME') }}
</p>
<div class="space-y-3">
<div
v-for="row in lpStats.by_hostname"
:key="row.label"
class="flex items-center gap-3"
>
<span class="w-56 shrink-0 truncate text-xs text-n-slate-11">
{{ row.label }}
</span>
<div class="h-2 flex-1 rounded-full bg-n-slate-3">
<div
class="h-2 rounded-full bg-n-blue-8 transition-all"
:style="{
width:
Math.round((row.clicks / lpMaxClicks) * 100) + '%',
}"
/>
</div>
<span class="w-24 shrink-0 text-right text-xs text-n-slate-8">
{{ row.clicks }} {{ '·' }} {{ row.rate }}{{ '%' }}
</span>
</div>
</div>
</div>
<!-- Botão atualizar -->
<div class="flex justify-end">
<button
class="flex items-center gap-2 rounded-lg border border-n-weak px-3 py-1.5 text-xs text-n-slate-10 transition-colors hover:text-n-slate-12"
:disabled="lpLoading"
@click="fetchLpStats"
>
<span class="i-lucide-refresh-cw size-3" />
{{ t('CAPTAIN_REPORTS.LP.REFRESH') }}
</button>
</div>
</div>
</div>
</div>
</template>
</SettingsLayout>

View File

@ -37,7 +37,7 @@ class Leads::AttributionMatcherService
'link_de_origem' => click.source,
'campanha' => click.campanha,
'lp_hostname' => click.hostname,
'click_id' => click.id.to_s
'click_id' => click.click_id || click.id.to_s
}
end

View File

@ -230,6 +230,7 @@ Rails.application.routes.draw do
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
resources :custom_filters, only: [:index, :show, :create, :update, :destroy]
resource :lead_click_stats, only: [:show]
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
get :assignable_agents, on: :member
get :campaigns, on: :member

View File

@ -0,0 +1,5 @@
class AddClickIdToLeadClicks < ActiveRecord::Migration[7.0]
def change
add_column :lead_clicks, :click_id, :string
end
end

View File

@ -0,0 +1,21 @@
# Rastreamento de Cliques da Landing Page
### Objetivo
Garantir que os cliques na landing page sejam rastreados, capturando UTMs (origem, campanha) e vinculando-os aos contatos no Chatwoot.
### 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.
### Próximos Passos
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.
### Como Validar
Simular um clique com UTMs:
```bash
curl -X POST http://localhost:3000/track/click \
-H "Content-Type: application/json" \
-d '{"hostname": "teste.com", "click_id": "123", "lp": "https://teste.com/?utm_source=meta&utm_campaign=blackfriday"}'
```
Verificar se o contato criado posteriormente recebe as atribuições `link_de_origem` e `campanha`.