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
|
private
|
||||||
|
|
||||||
def resolved_inbox_id
|
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
|
end
|
||||||
|
|
||||||
def click_params
|
def click_params
|
||||||
{
|
base_params = {
|
||||||
inbox_id: resolved_inbox_id,
|
inbox_id: resolved_inbox_id,
|
||||||
ip: params[:ip].presence || request.remote_ip,
|
ip: params[:ip].presence || request.remote_ip,
|
||||||
user_agent: request.user_agent || params[:user_agent],
|
user_agent: request.user_agent || params[:user_agent],
|
||||||
@ -22,7 +23,22 @@ class Api::V1::TrackingController < ActionController::API
|
|||||||
source: params[:source],
|
source: params[:source],
|
||||||
campanha: params[:campanha],
|
campanha: params[:campanha],
|
||||||
lp: params[:lp],
|
lp: params[:lp],
|
||||||
|
click_id: params[:click_id],
|
||||||
status: :clicked
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@ -394,7 +394,23 @@
|
|||||||
"TABS": {
|
"TABS": {
|
||||||
"DASHBOARD": "Dashboard",
|
"DASHBOARD": "Dashboard",
|
||||||
"INSIGHTS": "AI Insights",
|
"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": {
|
"FILTER_DATE": {
|
||||||
"LABEL": "Period:",
|
"LABEL": "Period:",
|
||||||
|
|||||||
@ -395,7 +395,23 @@
|
|||||||
"TABS": {
|
"TABS": {
|
||||||
"DASHBOARD": "Dashboard",
|
"DASHBOARD": "Dashboard",
|
||||||
"INSIGHTS": "Insights IA",
|
"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": {
|
"FILTER_DATE": {
|
||||||
"LABEL": "Período:",
|
"LABEL": "Período:",
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
|
/* global axios */
|
||||||
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
|
import { ref, reactive, onMounted, onUnmounted, computed, watch } from 'vue';
|
||||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
@ -26,8 +27,30 @@ const tabs = [
|
|||||||
{ key: 'dashboard' },
|
{ key: 'dashboard' },
|
||||||
{ key: 'insights' },
|
{ key: 'insights' },
|
||||||
{ key: 'operational' },
|
{ 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 getPeriodDates = period => {
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
@ -106,6 +129,7 @@ onMounted(async () => {
|
|||||||
await store.dispatch('captainAssistants/get');
|
await store.dispatch('captainAssistants/get');
|
||||||
await store.dispatch('captainReports/fetchInsights', {});
|
await store.dispatch('captainReports/fetchInsights', {});
|
||||||
if (hasProcessingInsights.value) startPolling();
|
if (hasProcessingInsights.value) startPolling();
|
||||||
|
await fetchLpStats();
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
@ -215,10 +239,21 @@ const tabLabel = key => {
|
|||||||
dashboard: t('CAPTAIN_REPORTS.TABS.DASHBOARD'),
|
dashboard: t('CAPTAIN_REPORTS.TABS.DASHBOARD'),
|
||||||
insights: t('CAPTAIN_REPORTS.TABS.INSIGHTS'),
|
insights: t('CAPTAIN_REPORTS.TABS.INSIGHTS'),
|
||||||
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
|
operational: t('CAPTAIN_REPORTS.TABS.OPERATIONAL'),
|
||||||
|
landing_pages: 'Landing Pages',
|
||||||
};
|
};
|
||||||
return map[key] || key;
|
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 ──
|
// ── FAQ Quick-Add ──
|
||||||
const quickAddFaq = reactive({
|
const quickAddFaq = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
@ -1386,6 +1421,226 @@ const maxHandoffCount = computed(() =>
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</SettingsLayout>
|
</SettingsLayout>
|
||||||
|
|||||||
@ -37,7 +37,7 @@ class Leads::AttributionMatcherService
|
|||||||
'link_de_origem' => click.source,
|
'link_de_origem' => click.source,
|
||||||
'campanha' => click.campanha,
|
'campanha' => click.campanha,
|
||||||
'lp_hostname' => click.hostname,
|
'lp_hostname' => click.hostname,
|
||||||
'click_id' => click.id.to_s
|
'click_id' => click.click_id || click.id.to_s
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@ -230,6 +230,7 @@ Rails.application.routes.draw do
|
|||||||
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
resources :reporting_events, only: [:index] if ChatwootApp.enterprise?
|
||||||
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
resources :custom_attribute_definitions, only: [:index, :show, :create, :update, :destroy]
|
||||||
resources :custom_filters, 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
|
resources :inboxes, only: [:index, :show, :create, :update, :destroy] do
|
||||||
get :assignable_agents, on: :member
|
get :assignable_agents, on: :member
|
||||||
get :campaigns, 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