feat(landing): add public LP flow, attribution labels, and report filters
This commit is contained in:
parent
98252e968a
commit
a0fcf37e33
@ -39,6 +39,6 @@ class Api::V1::Accounts::LandingHostsController < Api::V1::Accounts::BaseControl
|
||||
end
|
||||
|
||||
def landing_host_params
|
||||
params.require(:landing_host).permit(:hostname, :unit_code, :active)
|
||||
params.require(:landing_host).permit(:hostname, :unit_code, :active, :auto_label)
|
||||
end
|
||||
end
|
||||
|
||||
@ -1,37 +1,83 @@
|
||||
class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController
|
||||
def index
|
||||
def show
|
||||
clicks = account_clicks
|
||||
total = clicks.count
|
||||
convs = clicks.where.not(conversation_id: nil).count
|
||||
render json: stats_payload(clicks)
|
||||
end
|
||||
|
||||
render json: {
|
||||
total_clicks: total,
|
||||
total_conversions: convs,
|
||||
conversion_rate: total.positive? ? (convs.to_f / total * 100).round(1) : 0,
|
||||
private
|
||||
|
||||
def account_clicks
|
||||
scope = LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id })
|
||||
scope = scope.where(inbox_id: params[:inbox_id]) if params[:inbox_id].present?
|
||||
apply_period_filter(scope)
|
||||
end
|
||||
|
||||
def apply_period_filter(scope)
|
||||
return scope unless params[:period_start].present? && params[:period_end].present?
|
||||
|
||||
start_at = Time.zone.parse(params[:period_start].to_s)&.beginning_of_day
|
||||
end_at = Time.zone.parse(params[:period_end].to_s)&.end_of_day
|
||||
return scope unless start_at && end_at
|
||||
|
||||
scope.where(created_at: start_at..end_at)
|
||||
end
|
||||
|
||||
def stats_payload(clicks)
|
||||
total_clicks = clicks.count
|
||||
total_conversions = clicks.where.not(conversation_id: nil).count
|
||||
total_non_converted = total_clicks - total_conversions
|
||||
|
||||
{
|
||||
total_clicks: total_clicks,
|
||||
total_conversions: total_conversions,
|
||||
total_non_converted: total_non_converted,
|
||||
drop_off_rate: percentage(total_non_converted, total_clicks),
|
||||
conversion_rate: percentage(total_conversions, total_clicks),
|
||||
unique_click_ids: clicks.where.not(click_id: [nil, '']).distinct.count(:click_id),
|
||||
unique_converted_contacts: clicks.where.not(contact_id: nil).distinct.count(:contact_id),
|
||||
daily: daily_breakdown(clicks),
|
||||
by_source: group_by(clicks, :source),
|
||||
by_campaign: group_by(clicks, :campanha),
|
||||
by_hostname: group_by(clicks, :hostname)
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
def percentage(part, total)
|
||||
return 0 unless total.positive?
|
||||
|
||||
def account_clicks
|
||||
LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id })
|
||||
(part.to_f / total * 100).round(1)
|
||||
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] }
|
||||
rows = clicks
|
||||
.group(column)
|
||||
.select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions")
|
||||
|
||||
grouped_rows = rows.map do |row|
|
||||
{
|
||||
label: row.public_send(column).presence || '(sem nome)',
|
||||
clicks: row.clicks,
|
||||
conversions: row.conversions,
|
||||
rate: row.clicks.positive? ? (row.conversions.to_f / row.clicks * 100).round(1) : 0
|
||||
}
|
||||
end
|
||||
|
||||
grouped_rows.sort_by { |row| -row[:clicks] }
|
||||
end
|
||||
|
||||
def daily_breakdown(clicks)
|
||||
rows = clicks
|
||||
.group('DATE(created_at)')
|
||||
.select('DATE(created_at) AS day, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions')
|
||||
.order('day ASC')
|
||||
|
||||
rows.map do |row|
|
||||
{
|
||||
day: row.day.to_s,
|
||||
clicks: row.clicks.to_i,
|
||||
conversions: row.conversions.to_i,
|
||||
non_converted: row.clicks.to_i - row.conversions.to_i
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
5
app/controllers/public/landing_pages_controller.rb
Normal file
5
app/controllers/public/landing_pages_controller.rb
Normal file
@ -0,0 +1,5 @@
|
||||
class Public::LandingPagesController < PublicController
|
||||
layout false
|
||||
|
||||
def show; end
|
||||
end
|
||||
@ -403,6 +403,11 @@
|
||||
"TOTAL_CLICKS": "Total Clicks",
|
||||
"TOTAL_CONVERSIONS": "Conversions (WhatsApp)",
|
||||
"CONVERSION_RATE": "Conversion Rate",
|
||||
"TOTAL_DROPOFF": "Drop-off (No conversation)",
|
||||
"DROPOFF_RATE": "Drop-off Rate",
|
||||
"UNIQUE_CONTACTS": "Unique contacts converted",
|
||||
"FUNNEL_TITLE": "Landing page funnel",
|
||||
"DAILY_TREND": "Daily click vs conversion trend",
|
||||
"BY_SOURCE": "Clicks by Source",
|
||||
"BY_CAMPAIGN": "Clicks by Campaign",
|
||||
"BY_HOSTNAME": "Clicks by Landing Page",
|
||||
@ -416,9 +421,14 @@
|
||||
"LABEL": "Period:",
|
||||
"START": "Start Date",
|
||||
"END": "End Date",
|
||||
"TODAY": "Today",
|
||||
"YESTERDAY": "Yesterday",
|
||||
"LAST_7_DAYS": "Last 7 days",
|
||||
"LAST_30_DAYS": "Last 30 days",
|
||||
"THIS_WEEK": "This week",
|
||||
"LAST_WEEK": "Last Week",
|
||||
"CURRENT_MONTH": "Current Month",
|
||||
"LAST_MONTH": "Last Month",
|
||||
"CUSTOM": "Custom",
|
||||
"SEPARATOR": "-"
|
||||
},
|
||||
|
||||
@ -404,6 +404,11 @@
|
||||
"TOTAL_CLICKS": "Total de Cliques",
|
||||
"TOTAL_CONVERSIONS": "Conversões (WhatsApp)",
|
||||
"CONVERSION_RATE": "Taxa de Conversão",
|
||||
"TOTAL_DROPOFF": "Perdas (sem conversa)",
|
||||
"DROPOFF_RATE": "Taxa de Perda",
|
||||
"UNIQUE_CONTACTS": "Contatos únicos convertidos",
|
||||
"FUNNEL_TITLE": "Funil da landing page",
|
||||
"DAILY_TREND": "Tendência diária de cliques vs conversões",
|
||||
"BY_SOURCE": "Cliques por Origem",
|
||||
"BY_CAMPAIGN": "Cliques por Campanha",
|
||||
"BY_HOSTNAME": "Cliques por Landing Page",
|
||||
@ -417,9 +422,14 @@
|
||||
"LABEL": "Período:",
|
||||
"START": "Data Início",
|
||||
"END": "Data Fim",
|
||||
"TODAY": "Hoje",
|
||||
"YESTERDAY": "Ontem",
|
||||
"LAST_7_DAYS": "Últimos 7 dias",
|
||||
"LAST_30_DAYS": "Últimos 30 dias",
|
||||
"THIS_WEEK": "Esta semana",
|
||||
"LAST_WEEK": "Semana Passada",
|
||||
"CURRENT_MONTH": "Mês Atual",
|
||||
"LAST_MONTH": "Mês Passado",
|
||||
"CUSTOM": "Personalizado",
|
||||
"SEPARATOR": "-"
|
||||
},
|
||||
|
||||
@ -18,7 +18,7 @@ const assistants = useMapGetter('captainAssistants/getRecords');
|
||||
|
||||
const activeTab = ref('dashboard');
|
||||
const selectedInboxId = ref(null);
|
||||
const selectedPeriod = ref('last_week');
|
||||
const selectedPeriod = ref('last_7_days');
|
||||
const customStartDate = ref('');
|
||||
const customEndDate = ref('');
|
||||
const expandedInsights = ref({});
|
||||
@ -33,32 +33,32 @@ const tabs = [
|
||||
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 => {
|
||||
function getPeriodDates(period) {
|
||||
const end = new Date();
|
||||
const start = new Date();
|
||||
const now = new Date();
|
||||
|
||||
switch (period) {
|
||||
case 'today':
|
||||
start.setFullYear(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
end.setFullYear(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
break;
|
||||
case 'yesterday':
|
||||
start.setFullYear(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
end.setFullYear(now.getFullYear(), now.getMonth(), now.getDate() - 1);
|
||||
break;
|
||||
case 'last_7_days':
|
||||
start.setDate(end.getDate() - 7);
|
||||
break;
|
||||
case 'last_30_days':
|
||||
start.setDate(end.getDate() - 30);
|
||||
break;
|
||||
case 'this_week': {
|
||||
const day = now.getDay();
|
||||
const diffToMonday = day === 0 ? 6 : day - 1;
|
||||
start.setDate(now.getDate() - diffToMonday);
|
||||
break;
|
||||
}
|
||||
case 'last_week': {
|
||||
const day = end.getDay();
|
||||
const diffToLastSunday = day === 0 ? 7 : day;
|
||||
@ -69,6 +69,10 @@ const getPeriodDates = period => {
|
||||
case 'current_month':
|
||||
start.setDate(1);
|
||||
break;
|
||||
case 'last_month':
|
||||
start.setMonth(start.getMonth() - 1, 1);
|
||||
end.setDate(0);
|
||||
break;
|
||||
case 'custom':
|
||||
return {
|
||||
period_start: customStartDate.value,
|
||||
@ -82,6 +86,30 @@ const getPeriodDates = period => {
|
||||
period_start: start.toISOString().split('T')[0],
|
||||
period_end: end.toISOString().split('T')[0],
|
||||
};
|
||||
}
|
||||
|
||||
const fetchLpStats = async () => {
|
||||
const user = store.getters['auth/getCurrentUser'];
|
||||
const accountId =
|
||||
user?.account_id || window.location.pathname.match(/accounts\/(\d+)/)?.[1];
|
||||
if (!accountId) return;
|
||||
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
|
||||
const params = {
|
||||
...(selectedInboxId.value && { inbox_id: selectedInboxId.value }),
|
||||
...(period_start && period_end && { period_start, period_end }),
|
||||
};
|
||||
lpLoading.value = true;
|
||||
try {
|
||||
const { data } = await axios.get(
|
||||
`/api/v1/accounts/${accountId}/lead_click_stats`,
|
||||
{ params }
|
||||
);
|
||||
lpStats.value = data;
|
||||
} catch {
|
||||
// silent
|
||||
} finally {
|
||||
lpLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
let pollInterval = null;
|
||||
@ -113,6 +141,17 @@ watch(hasProcessingInsights, newVal => {
|
||||
else stopPolling();
|
||||
});
|
||||
|
||||
watch(activeTab, async tab => {
|
||||
if (tab === 'landing_pages') await fetchLpStats();
|
||||
});
|
||||
|
||||
watch([customStartDate, customEndDate], async () => {
|
||||
if (selectedPeriod.value !== 'custom') return;
|
||||
if (activeTab.value !== 'landing_pages') return;
|
||||
if (!customStartDate.value || !customEndDate.value) return;
|
||||
await fetchLpStats();
|
||||
});
|
||||
|
||||
// Auto-expand first done insight when loaded
|
||||
watch(
|
||||
insights,
|
||||
@ -142,10 +181,12 @@ const onFilterChange = async event => {
|
||||
await store.dispatch('captainReports/fetchInsights', {
|
||||
inbox_id: selectedInboxId.value,
|
||||
});
|
||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||
};
|
||||
|
||||
const onPeriodChange = event => {
|
||||
const onPeriodChange = async event => {
|
||||
selectedPeriod.value = event.target.value;
|
||||
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||
};
|
||||
|
||||
const onGenerateInsight = async () => {
|
||||
@ -254,6 +295,11 @@ const lpMaxClicks = computed(() => {
|
||||
return Math.max(...all.map(r => r.clicks), 1);
|
||||
});
|
||||
|
||||
const lpDailyMax = computed(() => {
|
||||
if (!lpStats.value?.daily?.length) return 1;
|
||||
return Math.max(...lpStats.value.daily.map(r => r.clicks), 1);
|
||||
});
|
||||
|
||||
// ── FAQ Quick-Add ──
|
||||
const quickAddFaq = reactive({
|
||||
open: false,
|
||||
@ -485,15 +531,30 @@ const maxHandoffCount = computed(() =>
|
||||
:value="selectedPeriod"
|
||||
@change="onPeriodChange"
|
||||
>
|
||||
<option value="today">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.TODAY') }}
|
||||
</option>
|
||||
<option value="yesterday">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.YESTERDAY') }}
|
||||
</option>
|
||||
<option value="last_7_days">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_7_DAYS') }}
|
||||
</option>
|
||||
<option value="last_30_days">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_30_DAYS') }}
|
||||
</option>
|
||||
<option value="this_week">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.THIS_WEEK') }}
|
||||
</option>
|
||||
<option value="last_week">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_WEEK') }}
|
||||
</option>
|
||||
<option value="current_month">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.CURRENT_MONTH') }}
|
||||
</option>
|
||||
<option value="last_month">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_MONTH') }}
|
||||
</option>
|
||||
<option value="custom">
|
||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.CUSTOM') }}
|
||||
</option>
|
||||
@ -1457,7 +1518,7 @@ const maxHandoffCount = computed(() =>
|
||||
<!-- dados -->
|
||||
<div v-else class="space-y-6">
|
||||
<!-- KPI Cards -->
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3">
|
||||
<div class="grid grid-cols-2 gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<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 }}
|
||||
@ -1482,6 +1543,126 @@ const maxHandoffCount = computed(() =>
|
||||
{{ t('CAPTAIN_REPORTS.LP.CONVERSION_RATE') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
|
||||
<p class="text-2xl font-bold text-n-ruby-11">
|
||||
{{ lpStats.total_non_converted?.toLocaleString() ?? 0 }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-n-slate-9">
|
||||
{{ t('CAPTAIN_REPORTS.LP.TOTAL_DROPOFF') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
|
||||
<p class="text-2xl font-bold text-n-amber-11">
|
||||
{{ lpStats.drop_off_rate ?? 0 }}{{ '%' }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-n-slate-9">
|
||||
{{ t('CAPTAIN_REPORTS.LP.DROPOFF_RATE') }}
|
||||
</p>
|
||||
</div>
|
||||
<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.unique_converted_contacts?.toLocaleString() ?? 0 }}
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-n-slate-9">
|
||||
{{ t('CAPTAIN_REPORTS.LP.UNIQUE_CONTACTS') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div 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.FUNNEL_TITLE') }}
|
||||
</p>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_REPORTS.LP.TOTAL_CLICKS')
|
||||
}}</span>
|
||||
<span class="text-n-slate-9">{{
|
||||
lpStats.total_clicks ?? 0
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-n-slate-3">
|
||||
<div class="h-2 rounded-full bg-n-blue-8 w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_REPORTS.LP.TOTAL_CONVERSIONS')
|
||||
}}</span>
|
||||
<span class="text-n-slate-9">
|
||||
{{ lpStats.total_conversions ?? 0 }} {{ '·' }}
|
||||
{{ lpStats.conversion_rate ?? 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-n-slate-3">
|
||||
<div
|
||||
class="h-2 rounded-full bg-n-teal-8 transition-all"
|
||||
:style="{ width: `${lpStats.conversion_rate ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="mb-1 flex items-center justify-between text-xs">
|
||||
<span class="text-n-slate-11">{{
|
||||
t('CAPTAIN_REPORTS.LP.TOTAL_DROPOFF')
|
||||
}}</span>
|
||||
<span class="text-n-slate-9">
|
||||
{{ lpStats.total_non_converted ?? 0 }} {{ '·' }}
|
||||
{{ lpStats.drop_off_rate ?? 0 }}%
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 rounded-full bg-n-slate-3">
|
||||
<div
|
||||
class="h-2 rounded-full bg-n-ruby-8 transition-all"
|
||||
:style="{ width: `${lpStats.drop_off_rate ?? 0}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="lpStats.daily?.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.DAILY_TREND') }}
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<div
|
||||
v-for="row in lpStats.daily"
|
||||
:key="row.day"
|
||||
class="flex items-center gap-3"
|
||||
>
|
||||
<span class="w-20 shrink-0 text-xs text-n-slate-9">
|
||||
{{ formatDate(row.day) }}
|
||||
</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 / lpDailyMax) * 100)}%`,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
class="w-20 shrink-0 text-right text-xs text-n-slate-8"
|
||||
>
|
||||
{{ row.clicks }} {{ t('CAPTAIN_REPORTS.LP.CLICKS') }}
|
||||
{{ '·' }} {{ row.conversions }}
|
||||
{{ t('CAPTAIN_REPORTS.LP.CONV') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gráficos -->
|
||||
|
||||
@ -19,6 +19,7 @@ export default {
|
||||
landingHosts: [],
|
||||
newHostname: '',
|
||||
newUnitCode: '',
|
||||
newAutoLabel: '',
|
||||
isLoading: false,
|
||||
isSaving: false,
|
||||
labels: {
|
||||
@ -29,21 +30,29 @@ export default {
|
||||
empty: 'Nenhum domínio cadastrado ainda.',
|
||||
colHostname: 'Hostname',
|
||||
colCode: 'Código / Unidade',
|
||||
colLabel: 'Etiqueta automática',
|
||||
colPublicLink: 'Página pública',
|
||||
remove: 'Remover',
|
||||
open: 'Abrir',
|
||||
copy: 'Copiar link',
|
||||
addTitle: 'Adicionar Domínio',
|
||||
labelHostname: 'Hostname *',
|
||||
placeholderHostname: 'express.seuhotel.com.br',
|
||||
labelCode: 'Código Unidade',
|
||||
placeholderCode: 'EXPRESS',
|
||||
labelAutoLabel: 'Etiqueta automática',
|
||||
placeholderAutoLabel: 'lead_landing_express',
|
||||
labelSaving: 'Salvando...',
|
||||
labelAdd: 'Adicionar',
|
||||
hint: 'Informe o domínio exato sem https://, ex: landing.meusite.com.br',
|
||||
hint: 'Informe o domínio exato sem https://. Se quiser, defina uma etiqueta para aplicar automaticamente quando o lead converter.',
|
||||
errLoad: 'Erro ao carregar os domínios.',
|
||||
errAdd:
|
||||
'Erro ao adicionar domínio. Verifique se já existe ou o formato é válido.',
|
||||
errDel: 'Erro ao remover domínio.',
|
||||
successAdd: 'Domínio adicionado com sucesso!',
|
||||
successDel: 'Domínio removido.',
|
||||
successCopy: 'Link copiado!',
|
||||
errCopy: 'Não foi possível copiar o link.',
|
||||
},
|
||||
};
|
||||
},
|
||||
@ -90,11 +99,13 @@ export default {
|
||||
{
|
||||
hostname: cleanHostname,
|
||||
unit_code: this.newUnitCode.trim().toUpperCase(),
|
||||
auto_label: this.newAutoLabel.trim() || null,
|
||||
}
|
||||
);
|
||||
this.landingHosts.push(data);
|
||||
this.newHostname = '';
|
||||
this.newUnitCode = '';
|
||||
this.newAutoLabel = '';
|
||||
useAlert(this.labels.successAdd);
|
||||
} catch {
|
||||
useAlert(this.labels.errAdd);
|
||||
@ -115,6 +126,17 @@ export default {
|
||||
useAlert(this.labels.errDel);
|
||||
}
|
||||
},
|
||||
landingUrl(hostname) {
|
||||
return `https://${hostname}/lp`;
|
||||
},
|
||||
async copyLink(hostname) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(this.landingUrl(hostname));
|
||||
useAlert(this.labels.successCopy);
|
||||
} catch {
|
||||
useAlert(this.labels.errCopy);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@ -153,6 +175,12 @@ export default {
|
||||
<th class="text-left px-4 py-3 font-medium">
|
||||
{{ labels.colCode }}
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium">
|
||||
{{ labels.colLabel }}
|
||||
</th>
|
||||
<th class="text-left px-4 py-3 font-medium">
|
||||
{{ labels.colPublicLink }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right" />
|
||||
</tr>
|
||||
</thead>
|
||||
@ -168,6 +196,27 @@ export default {
|
||||
<td class="px-4 py-3 text-n-slate-11">
|
||||
{{ host.unit_code || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-n-slate-11">
|
||||
{{ host.auto_label || '—' }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-n-slate-11">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
class="text-xs text-n-brand hover:underline"
|
||||
:href="landingUrl(host.hostname)"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{{ labels.open }}
|
||||
</a>
|
||||
<button
|
||||
class="text-xs text-n-slate-10 hover:text-n-slate-12"
|
||||
@click="copyLink(host.hostname)"
|
||||
>
|
||||
{{ labels.copy }}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<button
|
||||
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors"
|
||||
@ -209,6 +258,17 @@ export default {
|
||||
@keyup.enter="addHost"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-44">
|
||||
<label class="block text-xs font-medium text-n-slate-11 mb-1">
|
||||
{{ labels.labelAutoLabel }}
|
||||
</label>
|
||||
<woot-input
|
||||
v-model="newAutoLabel"
|
||||
:placeholder="labels.placeholderAutoLabel"
|
||||
class="[&>input]:!mb-0"
|
||||
@keyup.enter="addHost"
|
||||
/>
|
||||
</div>
|
||||
<NextButton
|
||||
:label="addLabel"
|
||||
:disabled="!newHostname.trim() || isSaving"
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# active :boolean
|
||||
# auto_label :string
|
||||
# hostname :string
|
||||
# unit_code :string
|
||||
# created_at :datetime not null
|
||||
|
||||
@ -25,7 +25,7 @@ class Leads::AttributionMatcherService
|
||||
def find_matching_click
|
||||
base_query = LeadClick
|
||||
.where(status: :clicked, inbox_id: @inbox_id)
|
||||
.where('created_at > ?', 30.minutes.ago)
|
||||
.where('created_at > ?', 5.minutes.ago)
|
||||
|
||||
return base_query.where(ip: @inbound_ip).order(created_at: :desc).first if @inbound_ip.present?
|
||||
|
||||
@ -61,6 +61,17 @@ class Leads::AttributionMatcherService
|
||||
end
|
||||
|
||||
def apply_labels(click)
|
||||
@conversation.add_labels(['lead_meta']) if click.source.to_s.downcase.include?('meta')
|
||||
labels = []
|
||||
labels << 'lead_meta' if click.source.to_s.downcase.include?('meta')
|
||||
|
||||
host_label = auto_label_for(click)
|
||||
labels << host_label if host_label.present?
|
||||
|
||||
@conversation.add_labels(labels.uniq) if labels.any?
|
||||
end
|
||||
|
||||
def auto_label_for(click)
|
||||
hostname = click.hostname.to_s.strip.sub(%r{^https?://}, '').sub(%r{/.*$}, '')
|
||||
LandingHost.find_by(inbox_id: @inbox_id, hostname: hostname, active: true)&.auto_label.to_s.strip.presence
|
||||
end
|
||||
end
|
||||
|
||||
436
app/views/public/landing_pages/show.html.erb
Normal file
436
app/views/public/landing_pages/show.html.erb
Normal file
@ -0,0 +1,436 @@
|
||||
<!doctype html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Atendimento WhatsApp</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg-1: #040b18;
|
||||
--bg-2: #031325;
|
||||
--card: #0f1729;
|
||||
--card-border: #1f2c43;
|
||||
--text-1: #e7ecf6;
|
||||
--text-2: #96a2b5;
|
||||
--btn: #27c15b;
|
||||
--btn-text: #f4fff7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
color: var(--text-1);
|
||||
background:
|
||||
radial-gradient(circle at 50% 52%, rgba(31, 150, 91, 0.18), transparent 34%),
|
||||
radial-gradient(circle at 30% 30%, rgba(16, 92, 172, 0.16), transparent 46%),
|
||||
linear-gradient(170deg, var(--bg-2), var(--bg-1));
|
||||
}
|
||||
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: min(100%, 480px);
|
||||
border: 1px solid var(--card-border);
|
||||
background: rgba(15, 23, 41, 0.9);
|
||||
border-radius: 20px;
|
||||
padding: 34px 28px;
|
||||
text-align: center;
|
||||
box-shadow: 0 24px 56px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.logo-wrap {
|
||||
width: 82px;
|
||||
height: 82px;
|
||||
margin: 0 auto 18px;
|
||||
border-radius: 999px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(40, 215, 122, 0.16);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.logo-wrap img {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
border-radius: 8px;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: clamp(29px, 4.2vw, 40px);
|
||||
line-height: 1.15;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
p.subtitle {
|
||||
margin: 14px 0 26px;
|
||||
font-size: 18px;
|
||||
color: var(--text-2);
|
||||
}
|
||||
|
||||
.wa-button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 14px;
|
||||
padding: 18px 22px;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
color: var(--btn-text);
|
||||
background: var(--btn);
|
||||
cursor: pointer;
|
||||
transition: transform 0.14s ease, opacity 0.14s ease;
|
||||
}
|
||||
|
||||
.wa-button:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.wa-button:active {
|
||||
transform: translateY(0);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.foot {
|
||||
margin-top: 14px;
|
||||
font-size: 14px;
|
||||
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>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<div id="logoTapArea" class="logo-wrap" title="logo">
|
||||
<img id="logoImage" alt="logo" />
|
||||
</div>
|
||||
<h1 id="titleText"></h1>
|
||||
<p id="subtitleText" class="subtitle"></p>
|
||||
<button id="whatsButton" class="wa-button" type="button">Falar no WhatsApp</button>
|
||||
<div class="foot">Pagina segura · atendimento humano</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="adminOverlay" class="admin-overlay">
|
||||
<div class="admin-modal">
|
||||
<div class="admin-head">
|
||||
<h2>Painel Admin</h2>
|
||||
<button id="closeAdmin" class="close-btn" type="button">×</button>
|
||||
</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>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const defaults = {
|
||||
unit_name: "Hotel",
|
||||
title: "Atendimento imediato no WhatsApp",
|
||||
subtitle: "Clique e fale direto com a recepcao agora",
|
||||
phone: "556136131003",
|
||||
message: "Ola! Tenho interesse.",
|
||||
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 {
|
||||
await fetch("/track/click", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
async function openWhatsapp() {
|
||||
await sendTrack();
|
||||
const phone = (config.phone || "").replace(/[^\d]/g, "");
|
||||
const text = encodeURIComponent(whatsappText());
|
||||
window.location.href = `https://wa.me/${phone}?text=${text}`;
|
||||
}
|
||||
|
||||
let logoTapCount = 0;
|
||||
let logoTapTimer = null;
|
||||
|
||||
$("logoTapArea").addEventListener("click", () => {
|
||||
logoTapCount += 1;
|
||||
clearTimeout(logoTapTimer);
|
||||
logoTapTimer = setTimeout(() => {
|
||||
logoTapCount = 0;
|
||||
}, 1500);
|
||||
|
||||
if (logoTapCount >= 5) {
|
||||
logoTapCount = 0;
|
||||
$("adminOverlay").classList.add("open");
|
||||
}
|
||||
});
|
||||
|
||||
$("closeAdmin").addEventListener("click", () => {
|
||||
$("adminOverlay").classList.remove("open");
|
||||
});
|
||||
|
||||
$("adminOverlay").addEventListener("click", (e) => {
|
||||
if (e.target === $("adminOverlay")) $("adminOverlay").classList.remove("open");
|
||||
});
|
||||
|
||||
Object.keys(inputs).forEach((key) => {
|
||||
if (!inputs[key]) return;
|
||||
inputs[key].addEventListener("input", () => {
|
||||
config[key] = inputs[key].value;
|
||||
syncView();
|
||||
});
|
||||
});
|
||||
|
||||
$("f_logo_file").addEventListener("change", (e) => {
|
||||
const file = e.target.files && e.target.files[0];
|
||||
if (!file) return;
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
config.logo_url = String(reader.result || "");
|
||||
syncView();
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
|
||||
$("saveAdmin").addEventListener("click", () => {
|
||||
localStorage.setItem(storageKey, JSON.stringify(config));
|
||||
$("adminOverlay").classList.remove("open");
|
||||
});
|
||||
|
||||
$("resetAdmin").addEventListener("click", () => {
|
||||
localStorage.removeItem(storageKey);
|
||||
config = { ...defaults };
|
||||
syncView();
|
||||
});
|
||||
|
||||
$("whatsButton").addEventListener("click", openWhatsapp);
|
||||
syncView();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -37,6 +37,7 @@ Rails.application.routes.draw do
|
||||
|
||||
get '/health', to: 'health#show'
|
||||
get '/api', to: 'api#index'
|
||||
get '/lp', to: 'public/landing_pages#show'
|
||||
post '/track/click', to: 'api/v1/tracking#click'
|
||||
namespace :api, defaults: { format: 'json' } do
|
||||
namespace :v1 do
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
class AddAutoLabelToLandingHosts < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
add_column :landing_hosts, :auto_label, :string
|
||||
end
|
||||
end
|
||||
@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2026_03_02_211000) do
|
||||
# These extensions should be enabled to support this database
|
||||
enable_extension "pg_stat_statements"
|
||||
enable_extension "pg_trgm"
|
||||
@ -1560,6 +1560,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do
|
||||
t.boolean "active"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "auto_label"
|
||||
t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true
|
||||
end
|
||||
|
||||
@ -1576,6 +1577,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do
|
||||
t.integer "contact_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "click_id"
|
||||
t.index ["inbox_id", "ip", "status", "created_at"], name: "index_lead_clicks_on_inbox_id_and_ip_and_status_and_created_at"
|
||||
end
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user