feat: add landing click attribution tracking and stats endpoint
This commit is contained in:
parent
a73689dce4
commit
98252e968a
@ -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
|
||||
@ -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
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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:",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
5
db/migrate/20260302172500_add_click_id_to_lead_clicks.rb
Normal file
5
db/migrate/20260302172500_add_click_id_to_lead_clicks.rb
Normal file
@ -0,0 +1,5 @@
|
||||
class AddClickIdToLeadClicks < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :lead_clicks, :click_id, :string
|
||||
end
|
||||
end
|
||||
21
progresso/2026-03-02_rastreamento_cliques_lp.md
Normal file
21
progresso/2026-03-02_rastreamento_cliques_lp.md
Normal 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`.
|
||||
Loading…
Reference in New Issue
Block a user