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
|
end
|
||||||
|
|
||||||
def landing_host_params
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@ -1,37 +1,83 @@
|
|||||||
class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::LeadClickStatsController < Api::V1::Accounts::BaseController
|
||||||
def index
|
def show
|
||||||
clicks = account_clicks
|
clicks = account_clicks
|
||||||
total = clicks.count
|
render json: stats_payload(clicks)
|
||||||
convs = clicks.where.not(conversation_id: nil).count
|
end
|
||||||
|
|
||||||
render json: {
|
private
|
||||||
total_clicks: total,
|
|
||||||
total_conversions: convs,
|
def account_clicks
|
||||||
conversion_rate: total.positive? ? (convs.to_f / total * 100).round(1) : 0,
|
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_source: group_by(clicks, :source),
|
||||||
by_campaign: group_by(clicks, :campanha),
|
by_campaign: group_by(clicks, :campanha),
|
||||||
by_hostname: group_by(clicks, :hostname)
|
by_hostname: group_by(clicks, :hostname)
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
def percentage(part, total)
|
||||||
|
return 0 unless total.positive?
|
||||||
|
|
||||||
def account_clicks
|
(part.to_f / total * 100).round(1)
|
||||||
LeadClick.joins(:inbox).where(inboxes: { account_id: current_account.id })
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def group_by(clicks, column)
|
def group_by(clicks, column)
|
||||||
clicks
|
rows = clicks
|
||||||
.group(column)
|
.group(column)
|
||||||
.select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions")
|
.select("#{column}, COUNT(*) AS clicks, COUNT(conversation_id) AS conversions")
|
||||||
.map do |r|
|
|
||||||
{
|
grouped_rows = rows.map do |row|
|
||||||
label: r.public_send(column).presence || '(sem nome)',
|
{
|
||||||
clicks: r.clicks,
|
label: row.public_send(column).presence || '(sem nome)',
|
||||||
conversions: r.conversions,
|
clicks: row.clicks,
|
||||||
rate: r.clicks.positive? ? (r.conversions.to_f / r.clicks * 100).round(1) : 0
|
conversions: row.conversions,
|
||||||
}
|
rate: row.clicks.positive? ? (row.conversions.to_f / row.clicks * 100).round(1) : 0
|
||||||
end
|
}
|
||||||
.sort_by { |r| -r[:clicks] }
|
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
|
||||||
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_CLICKS": "Total Clicks",
|
||||||
"TOTAL_CONVERSIONS": "Conversions (WhatsApp)",
|
"TOTAL_CONVERSIONS": "Conversions (WhatsApp)",
|
||||||
"CONVERSION_RATE": "Conversion Rate",
|
"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_SOURCE": "Clicks by Source",
|
||||||
"BY_CAMPAIGN": "Clicks by Campaign",
|
"BY_CAMPAIGN": "Clicks by Campaign",
|
||||||
"BY_HOSTNAME": "Clicks by Landing Page",
|
"BY_HOSTNAME": "Clicks by Landing Page",
|
||||||
@ -416,9 +421,14 @@
|
|||||||
"LABEL": "Period:",
|
"LABEL": "Period:",
|
||||||
"START": "Start Date",
|
"START": "Start Date",
|
||||||
"END": "End Date",
|
"END": "End Date",
|
||||||
|
"TODAY": "Today",
|
||||||
|
"YESTERDAY": "Yesterday",
|
||||||
"LAST_7_DAYS": "Last 7 days",
|
"LAST_7_DAYS": "Last 7 days",
|
||||||
|
"LAST_30_DAYS": "Last 30 days",
|
||||||
|
"THIS_WEEK": "This week",
|
||||||
"LAST_WEEK": "Last Week",
|
"LAST_WEEK": "Last Week",
|
||||||
"CURRENT_MONTH": "Current Month",
|
"CURRENT_MONTH": "Current Month",
|
||||||
|
"LAST_MONTH": "Last Month",
|
||||||
"CUSTOM": "Custom",
|
"CUSTOM": "Custom",
|
||||||
"SEPARATOR": "-"
|
"SEPARATOR": "-"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -404,6 +404,11 @@
|
|||||||
"TOTAL_CLICKS": "Total de Cliques",
|
"TOTAL_CLICKS": "Total de Cliques",
|
||||||
"TOTAL_CONVERSIONS": "Conversões (WhatsApp)",
|
"TOTAL_CONVERSIONS": "Conversões (WhatsApp)",
|
||||||
"CONVERSION_RATE": "Taxa de Conversão",
|
"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_SOURCE": "Cliques por Origem",
|
||||||
"BY_CAMPAIGN": "Cliques por Campanha",
|
"BY_CAMPAIGN": "Cliques por Campanha",
|
||||||
"BY_HOSTNAME": "Cliques por Landing Page",
|
"BY_HOSTNAME": "Cliques por Landing Page",
|
||||||
@ -417,9 +422,14 @@
|
|||||||
"LABEL": "Período:",
|
"LABEL": "Período:",
|
||||||
"START": "Data Início",
|
"START": "Data Início",
|
||||||
"END": "Data Fim",
|
"END": "Data Fim",
|
||||||
|
"TODAY": "Hoje",
|
||||||
|
"YESTERDAY": "Ontem",
|
||||||
"LAST_7_DAYS": "Últimos 7 dias",
|
"LAST_7_DAYS": "Últimos 7 dias",
|
||||||
|
"LAST_30_DAYS": "Últimos 30 dias",
|
||||||
|
"THIS_WEEK": "Esta semana",
|
||||||
"LAST_WEEK": "Semana Passada",
|
"LAST_WEEK": "Semana Passada",
|
||||||
"CURRENT_MONTH": "Mês Atual",
|
"CURRENT_MONTH": "Mês Atual",
|
||||||
|
"LAST_MONTH": "Mês Passado",
|
||||||
"CUSTOM": "Personalizado",
|
"CUSTOM": "Personalizado",
|
||||||
"SEPARATOR": "-"
|
"SEPARATOR": "-"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,7 +18,7 @@ const assistants = useMapGetter('captainAssistants/getRecords');
|
|||||||
|
|
||||||
const activeTab = ref('dashboard');
|
const activeTab = ref('dashboard');
|
||||||
const selectedInboxId = ref(null);
|
const selectedInboxId = ref(null);
|
||||||
const selectedPeriod = ref('last_week');
|
const selectedPeriod = ref('last_7_days');
|
||||||
const customStartDate = ref('');
|
const customStartDate = ref('');
|
||||||
const customEndDate = ref('');
|
const customEndDate = ref('');
|
||||||
const expandedInsights = ref({});
|
const expandedInsights = ref({});
|
||||||
@ -33,32 +33,32 @@ const tabs = [
|
|||||||
const lpStats = ref(null);
|
const lpStats = ref(null);
|
||||||
const lpLoading = ref(false);
|
const lpLoading = ref(false);
|
||||||
|
|
||||||
const fetchLpStats = async () => {
|
function getPeriodDates(period) {
|
||||||
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 end = new Date();
|
||||||
const start = new Date();
|
const start = new Date();
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
switch (period) {
|
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':
|
case 'last_7_days':
|
||||||
start.setDate(end.getDate() - 7);
|
start.setDate(end.getDate() - 7);
|
||||||
break;
|
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': {
|
case 'last_week': {
|
||||||
const day = end.getDay();
|
const day = end.getDay();
|
||||||
const diffToLastSunday = day === 0 ? 7 : day;
|
const diffToLastSunday = day === 0 ? 7 : day;
|
||||||
@ -69,6 +69,10 @@ const getPeriodDates = period => {
|
|||||||
case 'current_month':
|
case 'current_month':
|
||||||
start.setDate(1);
|
start.setDate(1);
|
||||||
break;
|
break;
|
||||||
|
case 'last_month':
|
||||||
|
start.setMonth(start.getMonth() - 1, 1);
|
||||||
|
end.setDate(0);
|
||||||
|
break;
|
||||||
case 'custom':
|
case 'custom':
|
||||||
return {
|
return {
|
||||||
period_start: customStartDate.value,
|
period_start: customStartDate.value,
|
||||||
@ -82,6 +86,30 @@ const getPeriodDates = period => {
|
|||||||
period_start: start.toISOString().split('T')[0],
|
period_start: start.toISOString().split('T')[0],
|
||||||
period_end: end.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;
|
let pollInterval = null;
|
||||||
@ -113,6 +141,17 @@ watch(hasProcessingInsights, newVal => {
|
|||||||
else stopPolling();
|
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
|
// Auto-expand first done insight when loaded
|
||||||
watch(
|
watch(
|
||||||
insights,
|
insights,
|
||||||
@ -142,10 +181,12 @@ const onFilterChange = async event => {
|
|||||||
await store.dispatch('captainReports/fetchInsights', {
|
await store.dispatch('captainReports/fetchInsights', {
|
||||||
inbox_id: selectedInboxId.value,
|
inbox_id: selectedInboxId.value,
|
||||||
});
|
});
|
||||||
|
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onPeriodChange = event => {
|
const onPeriodChange = async event => {
|
||||||
selectedPeriod.value = event.target.value;
|
selectedPeriod.value = event.target.value;
|
||||||
|
if (activeTab.value === 'landing_pages') await fetchLpStats();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onGenerateInsight = async () => {
|
const onGenerateInsight = async () => {
|
||||||
@ -254,6 +295,11 @@ const lpMaxClicks = computed(() => {
|
|||||||
return Math.max(...all.map(r => r.clicks), 1);
|
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 ──
|
// ── FAQ Quick-Add ──
|
||||||
const quickAddFaq = reactive({
|
const quickAddFaq = reactive({
|
||||||
open: false,
|
open: false,
|
||||||
@ -485,15 +531,30 @@ const maxHandoffCount = computed(() =>
|
|||||||
:value="selectedPeriod"
|
:value="selectedPeriod"
|
||||||
@change="onPeriodChange"
|
@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">
|
<option value="last_7_days">
|
||||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_7_DAYS') }}
|
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_7_DAYS') }}
|
||||||
</option>
|
</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">
|
<option value="last_week">
|
||||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_WEEK') }}
|
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_WEEK') }}
|
||||||
</option>
|
</option>
|
||||||
<option value="current_month">
|
<option value="current_month">
|
||||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.CURRENT_MONTH') }}
|
{{ t('CAPTAIN_REPORTS.FILTER_DATE.CURRENT_MONTH') }}
|
||||||
</option>
|
</option>
|
||||||
|
<option value="last_month">
|
||||||
|
{{ t('CAPTAIN_REPORTS.FILTER_DATE.LAST_MONTH') }}
|
||||||
|
</option>
|
||||||
<option value="custom">
|
<option value="custom">
|
||||||
{{ t('CAPTAIN_REPORTS.FILTER_DATE.CUSTOM') }}
|
{{ t('CAPTAIN_REPORTS.FILTER_DATE.CUSTOM') }}
|
||||||
</option>
|
</option>
|
||||||
@ -1457,7 +1518,7 @@ const maxHandoffCount = computed(() =>
|
|||||||
<!-- dados -->
|
<!-- dados -->
|
||||||
<div v-else class="space-y-6">
|
<div v-else class="space-y-6">
|
||||||
<!-- KPI Cards -->
|
<!-- 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">
|
<div class="rounded-2xl border border-n-weak bg-n-alpha-1 p-4">
|
||||||
<p class="text-2xl font-bold text-n-slate-12">
|
<p class="text-2xl font-bold text-n-slate-12">
|
||||||
{{ lpStats.total_clicks?.toLocaleString() ?? 0 }}
|
{{ lpStats.total_clicks?.toLocaleString() ?? 0 }}
|
||||||
@ -1482,6 +1543,126 @@ const maxHandoffCount = computed(() =>
|
|||||||
{{ t('CAPTAIN_REPORTS.LP.CONVERSION_RATE') }}
|
{{ t('CAPTAIN_REPORTS.LP.CONVERSION_RATE') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Gráficos -->
|
<!-- Gráficos -->
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export default {
|
|||||||
landingHosts: [],
|
landingHosts: [],
|
||||||
newHostname: '',
|
newHostname: '',
|
||||||
newUnitCode: '',
|
newUnitCode: '',
|
||||||
|
newAutoLabel: '',
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isSaving: false,
|
isSaving: false,
|
||||||
labels: {
|
labels: {
|
||||||
@ -29,21 +30,29 @@ export default {
|
|||||||
empty: 'Nenhum domínio cadastrado ainda.',
|
empty: 'Nenhum domínio cadastrado ainda.',
|
||||||
colHostname: 'Hostname',
|
colHostname: 'Hostname',
|
||||||
colCode: 'Código / Unidade',
|
colCode: 'Código / Unidade',
|
||||||
|
colLabel: 'Etiqueta automática',
|
||||||
|
colPublicLink: 'Página pública',
|
||||||
remove: 'Remover',
|
remove: 'Remover',
|
||||||
|
open: 'Abrir',
|
||||||
|
copy: 'Copiar link',
|
||||||
addTitle: 'Adicionar Domínio',
|
addTitle: 'Adicionar Domínio',
|
||||||
labelHostname: 'Hostname *',
|
labelHostname: 'Hostname *',
|
||||||
placeholderHostname: 'express.seuhotel.com.br',
|
placeholderHostname: 'express.seuhotel.com.br',
|
||||||
labelCode: 'Código Unidade',
|
labelCode: 'Código Unidade',
|
||||||
placeholderCode: 'EXPRESS',
|
placeholderCode: 'EXPRESS',
|
||||||
|
labelAutoLabel: 'Etiqueta automática',
|
||||||
|
placeholderAutoLabel: 'lead_landing_express',
|
||||||
labelSaving: 'Salvando...',
|
labelSaving: 'Salvando...',
|
||||||
labelAdd: 'Adicionar',
|
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.',
|
errLoad: 'Erro ao carregar os domínios.',
|
||||||
errAdd:
|
errAdd:
|
||||||
'Erro ao adicionar domínio. Verifique se já existe ou o formato é válido.',
|
'Erro ao adicionar domínio. Verifique se já existe ou o formato é válido.',
|
||||||
errDel: 'Erro ao remover domínio.',
|
errDel: 'Erro ao remover domínio.',
|
||||||
successAdd: 'Domínio adicionado com sucesso!',
|
successAdd: 'Domínio adicionado com sucesso!',
|
||||||
successDel: 'Domínio removido.',
|
successDel: 'Domínio removido.',
|
||||||
|
successCopy: 'Link copiado!',
|
||||||
|
errCopy: 'Não foi possível copiar o link.',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
@ -90,11 +99,13 @@ export default {
|
|||||||
{
|
{
|
||||||
hostname: cleanHostname,
|
hostname: cleanHostname,
|
||||||
unit_code: this.newUnitCode.trim().toUpperCase(),
|
unit_code: this.newUnitCode.trim().toUpperCase(),
|
||||||
|
auto_label: this.newAutoLabel.trim() || null,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
this.landingHosts.push(data);
|
this.landingHosts.push(data);
|
||||||
this.newHostname = '';
|
this.newHostname = '';
|
||||||
this.newUnitCode = '';
|
this.newUnitCode = '';
|
||||||
|
this.newAutoLabel = '';
|
||||||
useAlert(this.labels.successAdd);
|
useAlert(this.labels.successAdd);
|
||||||
} catch {
|
} catch {
|
||||||
useAlert(this.labels.errAdd);
|
useAlert(this.labels.errAdd);
|
||||||
@ -115,6 +126,17 @@ export default {
|
|||||||
useAlert(this.labels.errDel);
|
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>
|
</script>
|
||||||
@ -153,6 +175,12 @@ export default {
|
|||||||
<th class="text-left px-4 py-3 font-medium">
|
<th class="text-left px-4 py-3 font-medium">
|
||||||
{{ labels.colCode }}
|
{{ labels.colCode }}
|
||||||
</th>
|
</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" />
|
<th class="px-4 py-3 text-right" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -168,6 +196,27 @@ export default {
|
|||||||
<td class="px-4 py-3 text-n-slate-11">
|
<td class="px-4 py-3 text-n-slate-11">
|
||||||
{{ host.unit_code || '—' }}
|
{{ host.unit_code || '—' }}
|
||||||
</td>
|
</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">
|
<td class="px-4 py-3 text-right">
|
||||||
<button
|
<button
|
||||||
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors"
|
class="text-xs text-ruby-9 hover:text-ruby-11 font-medium transition-colors"
|
||||||
@ -209,6 +258,17 @@ export default {
|
|||||||
@keyup.enter="addHost"
|
@keyup.enter="addHost"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<NextButton
|
||||||
:label="addLabel"
|
:label="addLabel"
|
||||||
:disabled="!newHostname.trim() || isSaving"
|
:disabled="!newHostname.trim() || isSaving"
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
#
|
#
|
||||||
# id :bigint not null, primary key
|
# id :bigint not null, primary key
|
||||||
# active :boolean
|
# active :boolean
|
||||||
|
# auto_label :string
|
||||||
# hostname :string
|
# hostname :string
|
||||||
# unit_code :string
|
# unit_code :string
|
||||||
# created_at :datetime not null
|
# created_at :datetime not null
|
||||||
|
|||||||
@ -25,7 +25,7 @@ class Leads::AttributionMatcherService
|
|||||||
def find_matching_click
|
def find_matching_click
|
||||||
base_query = LeadClick
|
base_query = LeadClick
|
||||||
.where(status: :clicked, inbox_id: @inbox_id)
|
.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?
|
return base_query.where(ip: @inbound_ip).order(created_at: :desc).first if @inbound_ip.present?
|
||||||
|
|
||||||
@ -61,6 +61,17 @@ class Leads::AttributionMatcherService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def apply_labels(click)
|
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
|
||||||
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 '/health', to: 'health#show'
|
||||||
get '/api', to: 'api#index'
|
get '/api', to: 'api#index'
|
||||||
|
get '/lp', to: 'public/landing_pages#show'
|
||||||
post '/track/click', to: 'api/v1/tracking#click'
|
post '/track/click', to: 'api/v1/tracking#click'
|
||||||
namespace :api, defaults: { format: 'json' } do
|
namespace :api, defaults: { format: 'json' } do
|
||||||
namespace :v1 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.
|
# 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
|
# These extensions should be enabled to support this database
|
||||||
enable_extension "pg_stat_statements"
|
enable_extension "pg_stat_statements"
|
||||||
enable_extension "pg_trgm"
|
enable_extension "pg_trgm"
|
||||||
@ -1560,6 +1560,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do
|
|||||||
t.boolean "active"
|
t.boolean "active"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.string "auto_label"
|
||||||
t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true
|
t.index ["hostname"], name: "index_landing_hosts_on_hostname", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
@ -1576,6 +1577,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_03_02_154737) do
|
|||||||
t.integer "contact_id"
|
t.integer "contact_id"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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"
|
t.index ["inbox_id", "ip", "status", "created_at"], name: "index_lead_clicks_on_inbox_id_and_ip_and_status_and_created_at"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user