diff --git a/app/controllers/api/v1/accounts/landing_hosts_controller.rb b/app/controllers/api/v1/accounts/landing_hosts_controller.rb index c1c23b6e7..5c7343cec 100644 --- a/app/controllers/api/v1/accounts/landing_hosts_controller.rb +++ b/app/controllers/api/v1/accounts/landing_hosts_controller.rb @@ -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 diff --git a/app/controllers/api/v1/accounts/lead_click_stats_controller.rb b/app/controllers/api/v1/accounts/lead_click_stats_controller.rb index 49077e41b..4a4366358 100644 --- a/app/controllers/api/v1/accounts/lead_click_stats_controller.rb +++ b/app/controllers/api/v1/accounts/lead_click_stats_controller.rb @@ -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 diff --git a/app/controllers/public/landing_pages_controller.rb b/app/controllers/public/landing_pages_controller.rb new file mode 100644 index 000000000..32e21a338 --- /dev/null +++ b/app/controllers/public/landing_pages_controller.rb @@ -0,0 +1,5 @@ +class Public::LandingPagesController < PublicController + layout false + + def show; end +end diff --git a/app/javascript/dashboard/i18n/locale/en/captain.json b/app/javascript/dashboard/i18n/locale/en/captain.json index f0bdf1ded..a90081d19 100644 --- a/app/javascript/dashboard/i18n/locale/en/captain.json +++ b/app/javascript/dashboard/i18n/locale/en/captain.json @@ -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": "-" }, diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json index 89f6c6f60..f0a51be2c 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json @@ -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": "-" }, diff --git a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue index 48c3f119c..59a6f03ea 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/captain/reports/Index.vue @@ -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" > + + + + + @@ -1457,7 +1518,7 @@ const maxHandoffCount = computed(() =>
-
+

{{ lpStats.total_clicks?.toLocaleString() ?? 0 }} @@ -1482,6 +1543,126 @@ const maxHandoffCount = computed(() => {{ t('CAPTAIN_REPORTS.LP.CONVERSION_RATE') }}

+
+

+ {{ lpStats.total_non_converted?.toLocaleString() ?? 0 }} +

+

+ {{ t('CAPTAIN_REPORTS.LP.TOTAL_DROPOFF') }} +

+
+
+

+ {{ lpStats.drop_off_rate ?? 0 }}{{ '%' }} +

+

+ {{ t('CAPTAIN_REPORTS.LP.DROPOFF_RATE') }} +

+
+
+

+ {{ lpStats.unique_converted_contacts?.toLocaleString() ?? 0 }} +

+

+ {{ t('CAPTAIN_REPORTS.LP.UNIQUE_CONTACTS') }} +

+
+
+ +
+
+

+ {{ t('CAPTAIN_REPORTS.LP.FUNNEL_TITLE') }} +

+
+
+
+ {{ + t('CAPTAIN_REPORTS.LP.TOTAL_CLICKS') + }} + {{ + lpStats.total_clicks ?? 0 + }} +
+
+
+
+
+
+
+ {{ + t('CAPTAIN_REPORTS.LP.TOTAL_CONVERSIONS') + }} + + {{ lpStats.total_conversions ?? 0 }} {{ '·' }} + {{ lpStats.conversion_rate ?? 0 }}% + +
+
+
+
+
+
+
+ {{ + t('CAPTAIN_REPORTS.LP.TOTAL_DROPOFF') + }} + + {{ lpStats.total_non_converted ?? 0 }} {{ '·' }} + {{ lpStats.drop_off_rate ?? 0 }}% + +
+
+
+
+
+
+
+ +
+

+ {{ t('CAPTAIN_REPORTS.LP.DAILY_TREND') }} +

+
+
+ + {{ formatDate(row.day) }} + +
+
+
+ + {{ row.clicks }} {{ t('CAPTAIN_REPORTS.LP.CLICKS') }} + {{ '·' }} {{ row.conversions }} + {{ t('CAPTAIN_REPORTS.LP.CONV') }} + +
+
+
diff --git a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue index 12d0c361f..de5cdde2b 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/inbox/settingsPage/LandingHostsConfig.vue @@ -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); + } + }, }, }; @@ -153,6 +175,12 @@ export default { {{ labels.colCode }} + + {{ labels.colLabel }} + + + {{ labels.colPublicLink }} + @@ -168,6 +196,27 @@ export default { {{ host.unit_code || '—' }} + + {{ host.auto_label || '—' }} + + +
+ + {{ labels.open }} + + +
+
+
+ + +
?', 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 diff --git a/app/views/public/landing_pages/show.html.erb b/app/views/public/landing_pages/show.html.erb new file mode 100644 index 000000000..e2b271ea7 --- /dev/null +++ b/app/views/public/landing_pages/show.html.erb @@ -0,0 +1,436 @@ + + + + + + Atendimento WhatsApp + + + +
+
+
+ logo +
+

+

+ +
Pagina segura · atendimento humano
+
+
+ +
+
+
+

Painel Admin

+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
+
+ +
+ Mensagem que sera enviada: +
+
+ +
+ + +
+
+
+ + + + diff --git a/config/routes.rb b/config/routes.rb index 734d87d4c..eb676c2bd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260302211000_add_auto_label_to_landing_hosts.rb b/db/migrate/20260302211000_add_auto_label_to_landing_hosts.rb new file mode 100644 index 000000000..e50f45fb7 --- /dev/null +++ b/db/migrate/20260302211000_add_auto_label_to_landing_hosts.rb @@ -0,0 +1,5 @@ +class AddAutoLabelToLandingHosts < ActiveRecord::Migration[7.0] + def change + add_column :landing_hosts, :auto_label, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 4e3105fb4..89542cfc6 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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