feat(landing): add public LP flow, attribution labels, and report filters

This commit is contained in:
Rodrigo Borba 2026-03-02 18:57:22 -03:00
parent 98252e968a
commit a0fcf37e33
13 changed files with 817 additions and 49 deletions

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,5 @@
class Public::LandingPagesController < PublicController
layout false
def show; end
end

View File

@ -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": "-"
},

View File

@ -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": "-"
},

View File

@ -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 -->

View File

@ -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"

View File

@ -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

View File

@ -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

View 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>

View File

@ -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

View File

@ -0,0 +1,5 @@
class AddAutoLabelToLandingHosts < ActiveRecord::Migration[7.0]
def change
add_column :landing_hosts, :auto_label, :string
end
end

View File

@ -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