Upgrade heatmaps stile Hotjar: movimenti mouse e anteprima pagina live.

Traccia i movimenti aggregati, overlay a gradienti sull’iframe della pagina e toggle click/move in admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 23:03:12 +02:00
co-authored by Cursor
parent 8a07c5d569
commit b84321346f
26 changed files with 361 additions and 96 deletions
@@ -12,27 +12,29 @@ module Admin
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to]) scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
click_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to]) cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to])
click_scope = click_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
clicks_by_path = click_scope.group(:page_path).sum(:click_count) clicks_by_path = cell_scope.group(:page_path).sum(:click_count)
moves_by_path = cell_scope.group(:page_path).sum(:move_count)
pageviews_by_path = scope.group(:page_path).sum(:pageview_count) pageviews_by_path = scope.group(:page_path).sum(:pageview_count)
scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples) scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples)
scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct) scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct)
max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct) max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct)
paths = (pageviews_by_path.keys + clicks_by_path.keys).uniq paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq
@pages = paths.map do |path| @pages = paths.map do |path|
samples = scroll_samples_by_path[path].to_i samples = scroll_samples_by_path[path].to_i
{ {
page_path: path, page_path: path,
pageviews: pageviews_by_path[path].to_i, pageviews: pageviews_by_path[path].to_i,
clicks: clicks_by_path[path].to_i, clicks: clicks_by_path[path].to_i,
moves: moves_by_path[path].to_i,
scroll_samples: samples, scroll_samples: samples,
scroll_sum: scroll_sum_by_path[path].to_i, scroll_sum: scroll_sum_by_path[path].to_i,
max_scroll: max_scroll_by_path[path].to_i max_scroll: max_scroll_by_path[path].to_i
} }
end.sort_by { |r| [-r[:pageviews], -r[:clicks], r[:page_path]] } end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
end end
def show def show
@@ -42,13 +44,28 @@ module Admin
@filters = { @filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date, from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today, to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence device: params[:device].presence,
layer: params[:layer].to_s
} }
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@cells = cells.group(:cell_x, :cell_y).sum(:click_count)
@max_clicks = @cells.values.max.to_i @click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count)
@filters[:layer] =
if %w[move click].include?(@filters[:layer])
@filters[:layer]
elsif @move_total.positive?
"move"
else
"click"
end
counter = @filters[:layer] == "click" ? :click_count : :move_count
@cells = cells.group(:cell_x, :cell_y).sum(counter)
@max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to]) stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device]) stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@@ -58,6 +75,7 @@ module Admin
@max_scroll = stats.maximum(:max_scroll_pct).to_i @max_scroll = stats.maximum(:max_scroll_pct).to_i
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0 @avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
@grid = AnalyticsPageCell::GRID_SIZE @grid = AnalyticsPageCell::GRID_SIZE
@preview_url = preview_url_for(@page_path)
end end
private private
@@ -69,5 +87,12 @@ module Admin
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end end
def preview_url_for(path)
return nil if path.blank?
return nil if path.include?(":")
"#{MatchLiveTv.app_public_url.chomp('/')}#{path}"
end
end end
end end
+2 -1
View File
@@ -3,11 +3,12 @@
class AnalyticsEvent < ApplicationRecord class AnalyticsEvent < ApplicationRecord
self.table_name = "analytics_events" self.table_name = "analytics_events"
EVENT_TYPES = %w[click scroll pageview].freeze EVENT_TYPES = %w[click scroll pageview move].freeze
DEVICES = %w[mobile tablet desktop].freeze DEVICES = %w[mobile tablet desktop].freeze
validates :page_path, presence: true validates :page_path, presence: true
validates :device, inclusion: { in: DEVICES } validates :device, inclusion: { in: DEVICES }
validates :event_type, inclusion: { in: EVENT_TYPES } validates :event_type, inclusion: { in: EVENT_TYPES }
validates :occurred_at, presence: true validates :occurred_at, presence: true
validates :weight, numericality: { greater_than: 0, less_than_or_equal_to: 500 }
end end
@@ -9,4 +9,5 @@ class AnalyticsPageCell < ApplicationRecord
validates :device, inclusion: { in: AnalyticsEvent::DEVICES } validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) } validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) }
validates :click_count, numericality: { greater_than_or_equal_to: 0 } validates :click_count, numericality: { greater_than_or_equal_to: 0 }
validates :move_count, numericality: { greater_than_or_equal_to: 0 }
end end
+7 -7
View File
@@ -10,14 +10,14 @@ module Analytics
break if ids.empty? break if ids.empty?
events = AnalyticsEvent.where(id: ids).to_a events = AnalyticsEvent.where(id: ids).to_a
apply_clicks(events.select { |e| e.event_type == "click" }) apply_points(events.select { |e| e.event_type == "click" }, :click_count)
apply_stats(events.reject { |e| e.event_type == "click" }) apply_points(events.select { |e| e.event_type == "move" }, :move_count)
apply_stats(events.select { |e| %w[pageview scroll].include?(e.event_type) })
AnalyticsEvent.where(id: ids).delete_all AnalyticsEvent.where(id: ids).delete_all
end end
end end
def purge_old!(retention: 30.days) def purge_old!(retention: 30.days)
# Raw should already be empty after aggregate; keep as safety net.
AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all
AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all
AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all
@@ -25,7 +25,7 @@ module Analytics
private private
def apply_clicks(events) def apply_points(events, counter_attr)
return if events.empty? return if events.empty?
grid = AnalyticsPageCell::GRID_SIZE grid = AnalyticsPageCell::GRID_SIZE
@@ -36,7 +36,7 @@ module Analytics
cell_x = [[(e.x_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max cell_x = [[(e.x_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max
cell_y = [[(e.y_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max cell_y = [[(e.y_pct.to_f / 100 * grid).floor, grid - 1].min, 0].max
key = [e.occurred_at.in_time_zone.to_date, e.page_path, e.device, cell_x, cell_y] key = [e.occurred_at.in_time_zone.to_date, e.page_path, e.device, cell_x, cell_y]
grouped[key] += 1 grouped[key] += e.weight.to_i.clamp(1, 500)
end end
now = Time.current now = Time.current
@@ -44,7 +44,7 @@ module Analytics
cell = AnalyticsPageCell.find_or_initialize_by( cell = AnalyticsPageCell.find_or_initialize_by(
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
) )
cell.click_count = cell.click_count.to_i + count cell[counter_attr] = cell[counter_attr].to_i + count
cell.created_at ||= now cell.created_at ||= now
cell.updated_at = now cell.updated_at = now
cell.save! cell.save!
@@ -61,7 +61,7 @@ module Analytics
bucket = grouped[key] ||= { pageviews: 0, scroll_samples: 0, scroll_sum: 0, max_scroll: 0 } bucket = grouped[key] ||= { pageviews: 0, scroll_samples: 0, scroll_sum: 0, max_scroll: 0 }
case e.event_type case e.event_type
when "pageview" when "pageview"
bucket[:pageviews] += 1 bucket[:pageviews] += e.weight.to_i.clamp(1, 500)
when "scroll" when "scroll"
pct = e.scroll_pct.to_f.round pct = e.scroll_pct.to_f.round
bucket[:scroll_samples] += 1 bucket[:scroll_samples] += 1
+11 -3
View File
@@ -2,8 +2,8 @@
module Analytics module Analytics
class Ingest class Ingest
MAX_BATCH = 50 MAX_BATCH = 80
RATE_LIMIT_PER_MINUTE = 60 RATE_LIMIT_PER_MINUTE = 90
Result = Struct.new(:accepted, :rejected, :rate_limited, keyword_init: true) Result = Struct.new(:accepted, :rejected, :rate_limited, keyword_init: true)
@@ -62,11 +62,17 @@ module Analytics
return nil if page_path.length > 200 return nil if page_path.length > 200
occurred_at = parse_time(data[:ts] || data[:occurred_at]) || Time.current occurred_at = parse_time(data[:ts] || data[:occurred_at]) || Time.current
weight = data[:n].presence || data[:weight].presence || 1
weight = Integer(weight)
weight = 1 if weight < 1
weight = 500 if weight > 500
attrs = { attrs = {
page_path: page_path, page_path: page_path,
device: device, device: device,
event_type: event_type, event_type: event_type,
occurred_at: occurred_at, occurred_at: occurred_at,
weight: weight,
x_pct: nil, x_pct: nil,
y_pct: nil, y_pct: nil,
scroll_pct: nil, scroll_pct: nil,
@@ -75,7 +81,7 @@ module Analytics
} }
case event_type case event_type
when "click" when "click", "move"
x = clamp_pct(data[:x] || data[:x_pct]) x = clamp_pct(data[:x] || data[:x_pct])
y = clamp_pct(data[:y] || data[:y_pct]) y = clamp_pct(data[:y] || data[:y_pct])
return nil if x.nil? || y.nil? return nil if x.nil? || y.nil?
@@ -92,6 +98,8 @@ module Analytics
end end
attrs attrs
rescue ArgumentError, TypeError
nil
end end
def clamp_pct(value) def clamp_pct(value)
@@ -40,6 +40,7 @@
<tr> <tr>
<th><%= t("admin.analytics.index.table.path") %></th> <th><%= t("admin.analytics.index.table.path") %></th>
<th><%= t("admin.analytics.index.table.pageviews") %></th> <th><%= t("admin.analytics.index.table.pageviews") %></th>
<th><%= t("admin.analytics.index.table.moves") %></th>
<th><%= t("admin.analytics.index.table.clicks") %></th> <th><%= t("admin.analytics.index.table.clicks") %></th>
<th><%= t("admin.analytics.index.table.avg_scroll") %></th> <th><%= t("admin.analytics.index.table.avg_scroll") %></th>
<th><%= t("admin.analytics.index.table.max_scroll") %></th> <th><%= t("admin.analytics.index.table.max_scroll") %></th>
@@ -52,6 +53,7 @@
<tr> <tr>
<td><code class="admin-mono"><%= row[:page_path] %></code></td> <td><code class="admin-mono"><%= row[:page_path] %></code></td>
<td><%= row[:pageviews] %></td> <td><%= row[:pageviews] %></td>
<td><%= row[:moves] %></td>
<td><%= row[:clicks] %></td> <td><%= row[:clicks] %></td>
<td class="muted"><%= avg %>%</td> <td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td> <td class="muted"><%= row[:max_scroll] %>%</td>
+38 -31
View File
@@ -30,6 +30,17 @@
@filters[:device] @filters[:device]
) %> ) %>
</label> </label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.layer") %></span>
<%= select_tag :layer,
options_for_select(
[
[t("admin.analytics.filters.layer_move"), "move"],
[t("admin.analytics.filters.layer_click"), "click"]
],
@filters[:layer]
) %>
</label>
</div> </div>
<div class="admin-filter-actions"> <div class="admin-filter-actions">
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> <%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
@@ -45,9 +56,13 @@
<dt><%= t("admin.analytics.index.table.pageviews") %></dt> <dt><%= t("admin.analytics.index.table.pageviews") %></dt>
<dd><%= @pageviews %></dd> <dd><%= @pageviews %></dd>
</div> </div>
<div>
<dt><%= t("admin.analytics.index.table.moves") %></dt>
<dd><%= @move_total %></dd>
</div>
<div> <div>
<dt><%= t("admin.analytics.index.table.clicks") %></dt> <dt><%= t("admin.analytics.index.table.clicks") %></dt>
<dd><%= @cells.values.sum %></dd> <dd><%= @click_total %></dd>
</div> </div>
<div> <div>
<dt><%= t("admin.analytics.index.table.avg_scroll") %></dt> <dt><%= t("admin.analytics.index.table.avg_scroll") %></dt>
@@ -73,43 +88,35 @@
</div> </div>
<section class="panel"> <section class="panel">
<h3><%= t("admin.analytics.show.heatmap_title") %></h3> <h3>
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
</h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
<% if @cells.any? %> <% if @cells.any? %>
<div class="admin-heatmap-wrap"> <div class="admin-heatmap-stage" id="admin-heatmap-stage">
<% if @preview_url.present? %>
<iframe
class="admin-heatmap-frame"
src="<%= @preview_url %>"
title="<%= t("admin.analytics.show.preview_title") %>"
loading="lazy"
referrerpolicy="no-referrer"
></iframe>
<% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.preview_unavailable") %></p>
<% end %>
<canvas <canvas
id="admin-heatmap" id="admin-heatmap"
class="admin-heatmap" class="admin-heatmap-overlay"
width="800"
height="1200"
data-grid="<%= @grid %>" data-grid="<%= @grid %>"
data-max="<%= @max_clicks %>" data-max="<%= [@max_weight, 1].max %>"
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>" data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
></canvas> ></canvas>
</div> </div>
<script> <script src="/admin-analytics-heatmap.js?v=1" defer></script>
(function () {
var canvas = document.getElementById("admin-heatmap");
if (!canvas) return;
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
var cells = [];
try { cells = JSON.parse(canvas.getAttribute("data-cells") || "[]"); } catch (e) { cells = []; }
var ctx = canvas.getContext("2d");
var cw = canvas.width;
var ch = canvas.height;
ctx.fillStyle = "#0d0d12";
ctx.fillRect(0, 0, cw, ch);
var cellW = cw / grid;
var cellH = ch / grid;
cells.forEach(function (cell) {
var intensity = Math.min(1, cell.c / max);
var alpha = 0.15 + intensity * 0.75;
ctx.fillStyle = "rgba(229, 57, 53, " + alpha + ")";
ctx.fillRect(cell.x * cellW, cell.y * cellH, cellW + 0.5, cellH + 0.5);
});
})();
</script>
<% else %> <% else %>
<p class="empty"><%= t("admin.analytics.show.no_clicks") %></p> <p class="empty"><%= t("admin.analytics.show.no_points") %></p>
<% end %> <% end %>
</section> </section>
+1 -1
View File
@@ -26,7 +26,7 @@
<script src="/password-toggle.js?v=2" defer></script> <script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=1" defer></script> <script src="/site-analytics.js?v=2" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=2" defer></script>
</body> </body>
</html> </html>
@@ -21,7 +21,7 @@
<%= render "shared/marketing_footer" %> <%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=1" defer></script> <script src="/site-analytics.js?v=2" defer></script>
<script src="/cookie-consent.js?v=2" defer></script> <script src="/cookie-consent.js?v=2" defer></script>
</body> </body>
</html> </html>
+11 -1
View File
@@ -360,14 +360,18 @@ de:
any: Alle any: Alle
apply: Filtern apply: Filtern
reset: Zurücksetzen reset: Zurücksetzen
layer: Ebene
layer_move: Mausbewegungen
layer_click: Klicks
index: index:
title: Website-Analytics title: Website-Analytics
lead: Aggregierte First-Party-Heatmaps und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten. lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
none: Keine Daten im gewählten Zeitraum. none: Keine Daten im gewählten Zeitraum.
heatmap: Heatmap heatmap: Heatmap
table: table:
path: Seite path: Seite
pageviews: Pageviews pageviews: Pageviews
moves: Bewegungen
clicks: Klicks clicks: Klicks
avg_scroll: Scroll Ø avg_scroll: Scroll Ø
max_scroll: Scroll max max_scroll: Scroll max
@@ -377,6 +381,12 @@ de:
summary: Übersicht summary: Übersicht
scroll_depth: Scrolltiefe scroll_depth: Scrolltiefe
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%" scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
heatmap_moves: Mausbewegungs-Karte
heatmap_clicks: Klickkarte
heatmap_hint: Farbiges Overlay auf der Live-Seite (aktuelles Layout). Grün = wenig, Rot = viel.
preview_title: Seitenvorschau
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id). Nur Overlay.
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
heatmap_title: Klickkarte heatmap_title: Klickkarte
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum. no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
+11 -1
View File
@@ -360,14 +360,18 @@ en:
any: All any: All
apply: Filter apply: Filter
reset: Reset reset: Reset
layer: Layer
layer_move: Mouse moves
layer_click: Clicks
index: index:
title: Site analytics title: Site analytics
lead: Aggregated first-party heatmaps and scroll depth, only with analytics consent. No personal data. lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
none: No data in the selected period. none: No data in the selected period.
heatmap: Heatmap heatmap: Heatmap
table: table:
path: Page path: Page
pageviews: Pageviews pageviews: Pageviews
moves: Moves
clicks: Clicks clicks: Clicks
avg_scroll: Avg scroll avg_scroll: Avg scroll
max_scroll: Max scroll max_scroll: Max scroll
@@ -377,6 +381,12 @@ en:
summary: Summary summary: Summary
scroll_depth: Scroll depth scroll_depth: Scroll depth
scroll_hint: "Average %{avg}% · max %{max}%" scroll_hint: "Average %{avg}% · max %{max}%"
heatmap_moves: Mouse-move map
heatmap_clicks: Click map
heatmap_hint: Colored overlay on the live page (current layout). Green = low, red = high.
preview_title: Page preview
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id). Overlay only.
no_points: No aggregated points for this layer in the period.
heatmap_title: Click map heatmap_title: Click map
no_clicks: No aggregated clicks for this page in the period. no_clicks: No aggregated clicks for this page in the period.
+11 -1
View File
@@ -360,14 +360,18 @@ es:
any: Todos any: Todos
apply: Filtrar apply: Filtrar
reset: Restablecer reset: Restablecer
layer: Capa
layer_move: Movimientos del ratón
layer_click: Clics
index: index:
title: Analytics del sitio title: Analytics del sitio
lead: Heatmaps y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales. lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
none: No hay datos en el periodo seleccionado. none: No hay datos en el periodo seleccionado.
heatmap: Heatmap heatmap: Heatmap
table: table:
path: Página path: Página
pageviews: Pageviews pageviews: Pageviews
moves: Movimientos
clicks: Clics clicks: Clics
avg_scroll: Scroll medio avg_scroll: Scroll medio
max_scroll: Scroll máx max_scroll: Scroll máx
@@ -377,6 +381,12 @@ es:
summary: Resumen summary: Resumen
scroll_depth: Profundidad de scroll scroll_depth: Profundidad de scroll
scroll_hint: "Media %{avg}% · máximo %{max}%" scroll_hint: "Media %{avg}% · máximo %{max}%"
heatmap_moves: Mapa de movimientos
heatmap_clicks: Mapa de clics
heatmap_hint: Superposición de color sobre la página en vivo (layout actual). Verde = poco, rojo = mucho.
preview_title: Vista previa de la página
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id). Solo overlay.
no_points: No hay puntos agregados para esta capa en el periodo.
heatmap_title: Mapa de clics heatmap_title: Mapa de clics
no_clicks: No hay clics agregados para esta página en el periodo. no_clicks: No hay clics agregados para esta página en el periodo.
+11 -1
View File
@@ -360,14 +360,18 @@ fr:
any: Tous any: Tous
apply: Filtrer apply: Filtrer
reset: Réinitialiser reset: Réinitialiser
layer: Couche
layer_move: Mouvements souris
layer_click: Clics
index: index:
title: Analytics du site title: Analytics du site
lead: Heatmaps et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle. lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
none: Aucune donnée sur la période sélectionnée. none: Aucune donnée sur la période sélectionnée.
heatmap: Heatmap heatmap: Heatmap
table: table:
path: Page path: Page
pageviews: Pages vues pageviews: Pages vues
moves: Mouvements
clicks: Clics clicks: Clics
avg_scroll: Scroll moyen avg_scroll: Scroll moyen
max_scroll: Scroll max max_scroll: Scroll max
@@ -377,6 +381,12 @@ fr:
summary: Résumé summary: Résumé
scroll_depth: Profondeur de scroll scroll_depth: Profondeur de scroll
scroll_hint: "Moyenne %{avg}% · maximum %{max}%" scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
heatmap_moves: Carte des mouvements
heatmap_clicks: Carte des clics
heatmap_hint: Superposition colorée sur la page live (mise en page actuelle). Vert = faible, rouge = fort.
preview_title: Aperçu de la page
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id). Overlay seul.
no_points: Aucun point agrégé pour cette couche sur la période.
heatmap_title: Carte des clics heatmap_title: Carte des clics
no_clicks: Aucun clic agrégé pour cette page sur la période. no_clicks: Aucun clic agrégé pour cette page sur la période.
+11 -1
View File
@@ -381,14 +381,18 @@ it:
any: Tutti any: Tutti
apply: Filtra apply: Filtra
reset: Azzera reset: Azzera
layer: Livello
layer_move: Movimenti mouse
layer_click: Click
index: index:
title: Analytics sito title: Analytics sito
lead: Heatmap e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale. lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
none: Nessun dato nel periodo selezionato. none: Nessun dato nel periodo selezionato.
heatmap: Heatmap heatmap: Heatmap
table: table:
path: Pagina path: Pagina
pageviews: Pageview pageviews: Pageview
moves: Movimenti
clicks: Click clicks: Click
avg_scroll: Scroll medio avg_scroll: Scroll medio
max_scroll: Scroll max max_scroll: Scroll max
@@ -398,6 +402,12 @@ it:
summary: Riepilogo summary: Riepilogo
scroll_depth: Profondità di scroll scroll_depth: Profondità di scroll
scroll_hint: "Media %{avg}% · massimo %{max}%" scroll_hint: "Media %{avg}% · massimo %{max}%"
heatmap_moves: Mappa movimenti mouse
heatmap_clicks: Mappa click
heatmap_hint: Overlay colorato sulla pagina live (layout attuale). Verde = poco, rosso = molto.
preview_title: Anteprima pagina
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id). Mostra solo loverlay.
no_points: Nessun punto aggregato per questo livello nel periodo.
heatmap_title: Mappa click heatmap_title: Mappa click
no_clicks: Nessun click aggregato per questa pagina nel periodo. no_clicks: Nessun click aggregato per questa pagina nel periodo.
+3 -3
View File
@@ -88,7 +88,7 @@ de:
s10_title: 10. Sicherheit s10_title: 10. Sicherheit
s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns." s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns."
s11_title: 11. Cookies und ähnliche Technologien s11_title: 11. Cookies und ähnliche Technologien
s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, <strong>Google Analytics</strong> und <strong>aggregierte First-Party-Statistiken</strong> (Klick-/Scroll-Heatmaps) zur Produktverbesserung." s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, <strong>Google Analytics</strong> und <strong>aggregierte First-Party-Statistiken</strong> (Mausbewegungs-/Klick-/Scroll-Heatmaps) zur Produktverbesserung."
s11_p2_html: "Sie können Ihre Präferenzen jederzeit über den Link „Cookies verwalten“ in der Fußzeile ändern oder die %{cookie_policy_link} einsehen." s11_p2_html: "Sie können Ihre Präferenzen jederzeit über den Link „Cookies verwalten“ in der Fußzeile ändern oder die %{cookie_policy_link} einsehen."
s11_cookie_policy_link_text: Cookie-Richtlinie s11_cookie_policy_link_text: Cookie-Richtlinie
s12_title: 12. Änderungen s12_title: 12. Änderungen
@@ -169,7 +169,7 @@ de:
s4_1_row2_duration: 12 Monate s4_1_row2_duration: 12 Monate
s4_1_row2_provider: Match Live TV (First-Party) s4_1_row2_provider: Match Live TV (First-Party)
s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung) s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung)
s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir <strong>Google Analytics 4</strong>, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem <strong>aggregierte First-Party-Statistiken</strong> (Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten." s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir <strong>Google Analytics 4</strong>, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem <strong>aggregierte First-Party-Statistiken</strong> (Mausbewegungen, Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten."
s4_2_active_html: "Aktive Mess-ID auf der Website: <code>%{id}</code>" s4_2_active_html: "Aktive Mess-ID auf der Website: <code>%{id}</code>"
s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet. s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet.
table2_col_name: Name table2_col_name: Name
@@ -186,7 +186,7 @@ de:
s4_2_row3_duration: 24 Stunden s4_2_row3_duration: 24 Stunden
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / Scroll (First-Party)" s4_2_row4_name_html: "Heatmap / Scroll (First-Party)"
s4_2_row4_purpose: Aggregierte Klick- und Scrolltiefe-Zählungen nach Seite und Gerätetyp (keine Nutzerkennungen) s4_2_row4_purpose: Aggregierte Mausbewegungs-, Klick- und Scrolltiefe-Zählungen nach Seite und Gerätetyp (keine Nutzerkennungen)
s4_2_row4_duration: Aggregate bis zu 30 Tage s4_2_row4_duration: Aggregate bis zu 30 Tage
s4_2_row4_provider: Match Live TV (First-Party) s4_2_row4_provider: Match Live TV (First-Party)
s4_2_p2_html: "Sie können die Einwilligung über das Banner oder die Browsereinstellungen widerrufen. Google-Informationen: %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "Sie können die Einwilligung über das Banner oder die Browsereinstellungen widerrufen. Google-Informationen: %{google_privacy_link}, %{google_optout_link}."
+3 -3
View File
@@ -88,7 +88,7 @@ en:
s10_title: 10. Security s10_title: 10. Security
s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us." s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us."
s11_title: 11. Cookies and similar technologies s11_title: 11. Cookies and similar technologies
s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, <strong>Google Analytics</strong> and <strong>first-party aggregated statistics</strong> (click/scroll heatmaps) to improve the product." s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, <strong>Google Analytics</strong> and <strong>first-party aggregated statistics</strong> (mouse-move/click/scroll heatmaps) to improve the product."
s11_p2_html: "You can manage your preferences at any time via the “Manage cookies” link in the footer or consult the %{cookie_policy_link}." s11_p2_html: "You can manage your preferences at any time via the “Manage cookies” link in the footer or consult the %{cookie_policy_link}."
s11_cookie_policy_link_text: Cookie policy s11_cookie_policy_link_text: Cookie policy
s12_title: 12. Changes s12_title: 12. Changes
@@ -169,7 +169,7 @@ en:
s4_1_row2_duration: 12 months s4_1_row2_duration: 12 months
s4_1_row2_provider: Match Live TV (first-party) s4_1_row2_provider: Match Live TV (first-party)
s4_2_title: 4.2 Analytics (only with your consent) s4_2_title: 4.2 Analytics (only with your consent)
s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load <strong>Google Analytics 4</strong> to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect <strong>first-party aggregated statistics</strong> (clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages." s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load <strong>Google Analytics 4</strong> to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect <strong>first-party aggregated statistics</strong> (mouse movements, clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages."
s4_2_active_html: "Measurement ID active on the site: <code>%{id}</code>" s4_2_active_html: "Measurement ID active on the site: <code>%{id}</code>"
s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server. s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server.
table2_col_name: Name table2_col_name: Name
@@ -186,7 +186,7 @@ en:
s4_2_row3_duration: 24 hours s4_2_row3_duration: 24 hours
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (first-party)" s4_2_row4_name_html: "Heatmap / scroll (first-party)"
s4_2_row4_purpose: Aggregated click and scroll-depth counts by page and device type (no user identifiers) s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts by page and device type (no user identifiers)
s4_2_row4_duration: Aggregates kept up to 30 days s4_2_row4_duration: Aggregates kept up to 30 days
s4_2_row4_provider: Match Live TV (first-party) s4_2_row4_provider: Match Live TV (first-party)
s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "You can withdraw consent from the banner or from your browser settings. Google information: %{google_privacy_link}, %{google_optout_link}."
+3 -3
View File
@@ -88,7 +88,7 @@ es:
s10_title: 10. Seguridad s10_title: 10. Seguridad
s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos." s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos."
s11_title: 11. Cookies y tecnologías similares s11_title: 11. Cookies y tecnologías similares
s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, <strong>Google Analytics</strong> y <strong>estadísticas agregadas de primera parte</strong> (heatmaps de clics/scroll) para mejorar el producto." s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, <strong>Google Analytics</strong> y <strong>estadísticas agregadas de primera parte</strong> (heatmaps de movimientos del ratón/clics/scroll) para mejorar el producto."
s11_p2_html: "Puedes gestionar tus preferencias en cualquier momento desde el enlace «Gestionar cookies» en el pie de página o consultar la %{cookie_policy_link}." s11_p2_html: "Puedes gestionar tus preferencias en cualquier momento desde el enlace «Gestionar cookies» en el pie de página o consultar la %{cookie_policy_link}."
s11_cookie_policy_link_text: política de cookies s11_cookie_policy_link_text: política de cookies
s12_title: 12. Modificaciones s12_title: 12. Modificaciones
@@ -169,7 +169,7 @@ es:
s4_1_row2_duration: 12 meses s4_1_row2_duration: 12 meses
s4_1_row2_provider: Match Live TV (propia) s4_1_row2_provider: Match Live TV (propia)
s4_2_title: 4.2 Analíticas (solo con tu consentimiento) s4_2_title: 4.2 Analíticas (solo con tu consentimiento)
s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos <strong>Google Analytics 4</strong> para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos <strong>estadísticas agregadas de primera parte</strong> (clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay." s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos <strong>Google Analytics 4</strong> para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos <strong>estadísticas agregadas de primera parte</strong> (movimientos del ratón, clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay."
s4_2_active_html: "ID de medición activo en el sitio: <code>%{id}</code>" s4_2_active_html: "ID de medición activo en el sitio: <code>%{id}</code>"
s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor. s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor.
table2_col_name: Nombre table2_col_name: Nombre
@@ -186,7 +186,7 @@ es:
s4_2_row3_duration: 24 horas s4_2_row3_duration: 24 horas
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (propia)" s4_2_row4_name_html: "Heatmap / scroll (propia)"
s4_2_row4_purpose: Conteos agregados de clics y profundidad de scroll por página y tipo de dispositivo (sin identificadores de usuario) s4_2_row4_purpose: Conteos agregados de movimientos del ratón, clics y profundidad de scroll por página y tipo de dispositivo (sin identificadores de usuario)
s4_2_row4_duration: Agregados hasta 30 días s4_2_row4_duration: Agregados hasta 30 días
s4_2_row4_provider: Match Live TV (propia) s4_2_row4_provider: Match Live TV (propia)
s4_2_p2_html: "Puedes revocar el consentimiento desde el banner o desde la configuración del navegador. Información de Google: %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "Puedes revocar el consentimiento desde el banner o desde la configuración del navegador. Información de Google: %{google_privacy_link}, %{google_optout_link}."
+3 -3
View File
@@ -88,7 +88,7 @@ fr:
s10_title: 10. Sécurité s10_title: 10. Sécurité
s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous." s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous."
s11_title: 11. Cookies et technologies similaires s11_title: 11. Cookies et technologies similaires
s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, <strong>Google Analytics</strong> et des <strong>statistiques agrégées first-party</strong> (heatmaps clics/scroll) pour améliorer le produit." s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, <strong>Google Analytics</strong> et des <strong>statistiques agrégées first-party</strong> (heatmaps mouvements souris/clics/scroll) pour améliorer le produit."
s11_p2_html: "Vous pouvez gérer vos préférences à tout moment via le lien « Gérer les cookies » dans le pied de page ou consulter la %{cookie_policy_link}." s11_p2_html: "Vous pouvez gérer vos préférences à tout moment via le lien « Gérer les cookies » dans le pied de page ou consulter la %{cookie_policy_link}."
s11_cookie_policy_link_text: politique de cookies s11_cookie_policy_link_text: politique de cookies
s12_title: 12. Modifications s12_title: 12. Modifications
@@ -169,7 +169,7 @@ fr:
s4_1_row2_duration: 12 mois s4_1_row2_duration: 12 mois
s4_1_row2_provider: Match Live TV (première partie) s4_1_row2_provider: Match Live TV (première partie)
s4_2_title: 4.2 Analytics (uniquement avec votre consentement) s4_2_title: 4.2 Analytics (uniquement avec votre consentement)
s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons <strong>Google Analytics 4</strong> pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des <strong>statistiques agrégées first-party</strong> (clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay." s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons <strong>Google Analytics 4</strong> pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des <strong>statistiques agrégées first-party</strong> (mouvements de souris, clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay."
s4_2_active_html: "ID de mesure actif sur le site : <code>%{id}</code>" s4_2_active_html: "ID de mesure actif sur le site : <code>%{id}</code>"
s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur. s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur.
table2_col_name: Nom table2_col_name: Nom
@@ -186,7 +186,7 @@ fr:
s4_2_row3_duration: 24 heures s4_2_row3_duration: 24 heures
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (première partie)" s4_2_row4_name_html: "Heatmap / scroll (première partie)"
s4_2_row4_purpose: Compteurs agrégés de clics et de profondeur de scroll par page et type d'appareil (sans identifiant utilisateur) s4_2_row4_purpose: Compteurs agrégés de mouvements souris, clics et profondeur de scroll par page et type d'appareil (sans identifiant utilisateur)
s4_2_row4_duration: Agrégats conservés jusqu'à 30 jours s4_2_row4_duration: Agrégats conservés jusqu'à 30 jours
s4_2_row4_provider: Match Live TV (première partie) s4_2_row4_provider: Match Live TV (première partie)
s4_2_p2_html: "Vous pouvez retirer votre consentement depuis la bannière ou depuis les paramètres du navigateur. Informations Google : %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "Vous pouvez retirer votre consentement depuis la bannière ou depuis les paramètres du navigateur. Informations Google : %{google_privacy_link}, %{google_optout_link}."
+3 -3
View File
@@ -88,7 +88,7 @@ it:
s10_title: 10. Sicurezza s10_title: 10. Sicurezza
s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci." s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci."
s11_title: 11. Cookie e tecnologie simili s11_title: 11. Cookie e tecnologie simili
s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, <strong>Google Analytics</strong> e <strong>statistiche aggregate di prima parte</strong> (heatmap click/scroll) per migliorare il prodotto." s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, <strong>Google Analytics</strong> e <strong>statistiche aggregate di prima parte</strong> (heatmap movimenti mouse/click/scroll) per migliorare il prodotto."
s11_p2_html: "Puoi gestire le preferenze in qualsiasi momento dal link «Gestisci cookie» nel footer o consultare la %{cookie_policy_link}." s11_p2_html: "Puoi gestire le preferenze in qualsiasi momento dal link «Gestisci cookie» nel footer o consultare la %{cookie_policy_link}."
s11_cookie_policy_link_text: Cookie policy s11_cookie_policy_link_text: Cookie policy
s12_title: 12. Modifiche s12_title: 12. Modifiche
@@ -169,7 +169,7 @@ it:
s4_1_row2_duration: 12 mesi s4_1_row2_duration: 12 mesi
s4_1_row2_provider: Match Live TV (prima parte) s4_1_row2_provider: Match Live TV (prima parte)
s4_2_title: 4.2 Analytics (solo con il tuo consenso) s4_2_title: 4.2 Analytics (solo con il tuo consenso)
s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo <strong>Google Analytics 4</strong> per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy. In parallelo, con lo stesso consenso, raccogliamo <strong>statistiche aggregate di prima parte</strong> (click e profondità di scroll, senza identificare lutente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay." s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo <strong>Google Analytics 4</strong> per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy. In parallelo, con lo stesso consenso, raccogliamo <strong>statistiche aggregate di prima parte</strong> (movimenti del mouse, click e profondità di scroll, senza identificare lutente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay."
s4_2_active_html: "ID misurazione attivo sul sito: <code>%{id}</code>" s4_2_active_html: "ID misurazione attivo sul sito: <code>%{id}</code>"
s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta lID misurazione sul server. s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta lID misurazione sul server.
table2_col_name: Nome table2_col_name: Nome
@@ -186,7 +186,7 @@ it:
s4_2_row3_duration: 24 ore s4_2_row3_duration: 24 ore
s4_2_row3_provider: Google s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (prima parte)" s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
s4_2_row4_purpose: Conteggi aggregati di click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente) s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll per pagina e tipo di dispositivo (nessun identificativo utente)
s4_2_row4_duration: Aggregati fino a 30 giorni s4_2_row4_duration: Aggregati fino a 30 giorni
s4_2_row4_provider: Match Live TV (prima parte) s4_2_row4_provider: Match Live TV (prima parte)
s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}." s4_2_p2_html: "Puoi revocare il consenso dal banner o dalle impostazioni del browser. Informazioni Google: %{google_privacy_link}, %{google_optout_link}."
@@ -0,0 +1,8 @@
# frozen_string_literal: true
class AddMoveHeatmapToAnalytics < ActiveRecord::Migration[7.2]
def change
add_column :analytics_events, :weight, :integer, null: false, default: 1
add_column :analytics_page_cells, :move_count, :integer, null: false, default: 0
end
end
+3 -1
View File
@@ -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.2].define(version: 2026_08_20_200000) do ActiveRecord::Schema[7.2].define(version: 2026_08_20_210000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -61,6 +61,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_200000) do
t.datetime "occurred_at", null: false t.datetime "occurred_at", null: false
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.integer "weight", default: 1, null: false
t.index ["event_type", "occurred_at"], name: "index_analytics_events_on_event_type_and_occurred_at" t.index ["event_type", "occurred_at"], name: "index_analytics_events_on_event_type_and_occurred_at"
t.index ["occurred_at"], name: "index_analytics_events_on_occurred_at" t.index ["occurred_at"], name: "index_analytics_events_on_occurred_at"
end end
@@ -74,6 +75,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_200000) do
t.integer "click_count", default: 0, null: false t.integer "click_count", default: 0, null: false
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.integer "move_count", default: 0, null: false
t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true t.index ["day", "page_path", "device", "cell_x", "cell_y"], name: "index_analytics_page_cells_unique", unique: true
end end
+67
View File
@@ -0,0 +1,67 @@
/*! Admin heatmap overlay renderer (Hotjar-like radial blobs over page preview). */
(function () {
function colorFor(t) {
// green -> yellow -> red
if (t < 0.33) {
var k = t / 0.33;
return [67 + (255 - 67) * k, 160 + (235 - 160) * k, 71 * (1 - k)];
}
if (t < 0.66) {
var k2 = (t - 0.33) / 0.33;
return [255, 235 - (235 - 152) * k2, 0];
}
var k3 = (t - 0.66) / 0.34;
return [255, 152 * (1 - k3), 0];
}
function paint() {
var canvas = document.getElementById("admin-heatmap");
var stage = document.getElementById("admin-heatmap-stage");
if (!canvas || !stage) return;
var grid = parseInt(canvas.getAttribute("data-grid"), 10) || 40;
var max = parseInt(canvas.getAttribute("data-max"), 10) || 1;
var cells = [];
try {
cells = JSON.parse(canvas.getAttribute("data-cells") || "[]");
} catch (e) {
cells = [];
}
var width = Math.max(stage.clientWidth, 320);
var height = Math.max(stage.clientHeight, 480);
canvas.width = width;
canvas.height = height;
canvas.style.width = width + "px";
canvas.style.height = height + "px";
var ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, width, height);
var cellW = width / grid;
var cellH = height / grid;
var radius = Math.max(cellW, cellH) * 1.8;
cells.forEach(function (cell) {
var intensity = Math.min(1, cell.c / max);
if (intensity <= 0) return;
var cx = (cell.x + 0.5) * cellW;
var cy = (cell.y + 0.5) * cellH;
var rgb = colorFor(intensity);
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
grd.addColorStop(0, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "," + (0.2 + intensity * 0.55) + ")");
grd.addColorStop(1, "rgba(" + rgb[0] + "," + rgb[1] + "," + rgb[2] + ",0)");
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", paint);
} else {
paint();
}
window.addEventListener("resize", paint);
})();
+43 -9
View File
@@ -960,20 +960,54 @@ body.admin-body {
transform: translateX(-1px); transform: translateX(-1px);
} }
.admin-heatmap-wrap { .admin-heatmap-stage {
overflow: auto; position: relative;
max-height: 70vh; overflow: hidden;
min-height: 70vh;
max-height: 85vh;
border: 1px solid var(--card-border); border: 1px solid var(--card-border);
border-radius: 10px; border-radius: 10px;
background: #0d0d12; background: #111;
} }
.admin-heatmap { .admin-heatmap-frame {
display: block; position: absolute;
inset: 0;
width: 100%; width: 100%;
height: auto; height: 100%;
max-width: 800px; border: 0;
margin: 0 auto; background: #fff;
pointer-events: none;
}
.admin-heatmap-fallback {
position: absolute;
inset: 0;
background:
radial-gradient(circle at 20% 20%, #1e293b 0%, transparent 45%),
radial-gradient(circle at 80% 10%, #0f172a 0%, transparent 40%),
#0d0d12;
}
.admin-heatmap-fallback-note {
position: absolute;
z-index: 2;
left: 1rem;
top: 1rem;
margin: 0;
max-width: 28rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
background: rgba(0, 0, 0, 0.55);
}
.admin-heatmap-overlay {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 1;
pointer-events: none;
} }
+59 -12
View File
@@ -1,17 +1,21 @@
/*! Match Live TV first-party site analytics (click + scroll). Requires analytics cookie consent. */ /*! Match Live TV first-party site analytics (click + mouse move + scroll). Requires analytics cookie consent. */
(function () { (function () {
"use strict"; "use strict";
var ENDPOINT = "/analytics/events"; var ENDPOINT = "/analytics/events";
var STORAGE_KEY = "mltv_cookie_consent"; var STORAGE_KEY = "mltv_cookie_consent";
var COOKIE_NAME = "mltv_cookie_consent"; var COOKIE_NAME = "mltv_cookie_consent";
var FLUSH_MS = 8000; var FLUSH_MS = 6000;
var MAX_QUEUE = 40; var MAX_QUEUE = 60;
var MOVE_THROTTLE_MS = 180;
var MOVE_GRID = 40;
var queue = []; var queue = [];
var moveBuckets = {};
var maxScroll = 0; var maxScroll = 0;
var flushTimer = null; var flushTimer = null;
var started = false; var started = false;
var lastMoveAt = 0;
function excludedPath(pathname) { function excludedPath(pathname) {
var p = pathname || "/"; var p = pathname || "/";
@@ -44,6 +48,15 @@
return "desktop"; return "desktop";
} }
function pageSize() {
var doc = document.documentElement;
var body = document.body;
return {
width: Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1),
height: Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1)
};
}
function scrollPct() { function scrollPct() {
var doc = document.documentElement; var doc = document.documentElement;
var body = document.body; var body = document.body;
@@ -56,8 +69,7 @@
doc.clientHeight || 0 doc.clientHeight || 0
); );
var view = window.innerHeight || doc.clientHeight || 0; var view = window.innerHeight || doc.clientHeight || 0;
var maxScrollable = Math.max(height - view, 1); var pct = ((scrollTop + view) / Math.max(height, 1)) * 100;
var pct = ((scrollTop + view) / height) * 100;
if (scrollTop <= 0 && view >= height) return 100; if (scrollTop <= 0 && view >= height) return 100;
return Math.max(0, Math.min(100, Math.round(pct))); return Math.max(0, Math.min(100, Math.round(pct)));
} }
@@ -65,10 +77,31 @@
function pushEvent(evt) { function pushEvent(evt) {
if (!started) return; if (!started) return;
queue.push(evt); queue.push(evt);
if (queue.length >= MAX_QUEUE) flush(true); if (queue.length >= MAX_QUEUE) flush(false);
}
function flushMovesIntoQueue() {
var keys = Object.keys(moveBuckets);
if (!keys.length) return;
var device = deviceBucket();
var path = location.pathname;
var ts = Date.now();
keys.forEach(function (key) {
var parts = key.split(",");
var cx = parseInt(parts[0], 10);
var cy = parseInt(parts[1], 10);
var n = moveBuckets[key];
if (!n) return;
// cell center in %
var x = ((cx + 0.5) / MOVE_GRID) * 100;
var y = ((cy + 0.5) / MOVE_GRID) * 100;
pushEvent({ type: "move", path: path, device: device, x: x, y: y, n: n, ts: ts });
});
moveBuckets = {};
} }
function flush(useBeacon) { function flush(useBeacon) {
flushMovesIntoQueue();
if (!queue.length || !hasAnalyticsConsent()) { if (!queue.length || !hasAnalyticsConsent()) {
queue = []; queue = [];
return; return;
@@ -107,10 +140,7 @@
if (!(target instanceof Element)) return; if (!(target instanceof Element)) return;
if (target.closest("input, textarea, select, [contenteditable='true']")) return; if (target.closest("input, textarea, select, [contenteditable='true']")) return;
var doc = document.documentElement; var size = pageSize();
var body = document.body;
var width = Math.max(body.scrollWidth || 0, doc.scrollWidth || 0, window.innerWidth || 1);
var height = Math.max(body.scrollHeight || 0, doc.scrollHeight || 0, window.innerHeight || 1);
var x = event.pageX; var x = event.pageX;
var y = event.pageY; var y = event.pageY;
if (typeof x !== "number" || typeof y !== "number") return; if (typeof x !== "number" || typeof y !== "number") return;
@@ -119,12 +149,28 @@
type: "click", type: "click",
path: location.pathname, path: location.pathname,
device: deviceBucket(), device: deviceBucket(),
x: Math.max(0, Math.min(100, (x / width) * 100)), x: Math.max(0, Math.min(100, (x / size.width) * 100)),
y: Math.max(0, Math.min(100, (y / height) * 100)), y: Math.max(0, Math.min(100, (y / size.height) * 100)),
ts: Date.now() ts: Date.now()
}); });
} }
function onMouseMove(event) {
var now = Date.now();
if (now - lastMoveAt < MOVE_THROTTLE_MS) return;
lastMoveAt = now;
var size = pageSize();
var x = event.pageX;
var y = event.pageY;
if (typeof x !== "number" || typeof y !== "number") return;
var xPct = Math.max(0, Math.min(100, (x / size.width) * 100));
var yPct = Math.max(0, Math.min(100, (y / size.height) * 100));
var cx = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((xPct / 100) * MOVE_GRID)));
var cy = Math.min(MOVE_GRID - 1, Math.max(0, Math.floor((yPct / 100) * MOVE_GRID)));
var key = cx + "," + cy;
moveBuckets[key] = (moveBuckets[key] || 0) + 1;
}
function onScroll() { function onScroll() {
var pct = scrollPct(); var pct = scrollPct();
if (pct > maxScroll) maxScroll = pct; if (pct > maxScroll) maxScroll = pct;
@@ -157,6 +203,7 @@
maxScroll = scrollPct(); maxScroll = scrollPct();
trackPageview(); trackPageview();
document.addEventListener("click", onClick, true); document.addEventListener("click", onClick, true);
document.addEventListener("mousemove", onMouseMove, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true }); window.addEventListener("scroll", onScroll, { passive: true });
document.addEventListener("visibilitychange", function () { document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "hidden") { if (document.visibilityState === "hidden") {
@@ -22,7 +22,8 @@ RSpec.describe "Admin analytics", type: :request do
device: "desktop", device: "desktop",
cell_x: 10, cell_x: 10,
cell_y: 15, cell_y: 15,
click_count: 4 click_count: 4,
move_count: 40
) )
end end
@@ -31,12 +32,14 @@ RSpec.describe "Admin analytics", type: :request do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("/prezzi") expect(response.body).to include("/prezzi")
expect(response.body).to include("12") expect(response.body).to include("12")
expect(response.body).to include("40")
end end
it "mostra la heatmap di una pagina" do it "mostra la heatmap di una pagina" do
get admin_analytics_page_path, params: { page_path: "/prezzi" } get admin_analytics_page_path, params: { page_path: "/prezzi" }
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("admin-heatmap") expect(response.body).to include("admin-heatmap")
expect(response.body).to include("admin-heatmap-frame")
expect(response.body).to include("/prezzi") expect(response.body).to include("/prezzi")
end end
end end
+11 -1
View File
@@ -21,6 +21,15 @@ RSpec.describe "Analytics ingest", type: :request do
y: 50, y: 50,
ts: Time.current.to_i * 1000 ts: Time.current.to_i * 1000
}, },
{
type: "move",
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
device: "desktop",
x: 40,
y: 30,
n: 12,
ts: Time.current.to_i * 1000
},
{ {
type: "scroll", type: "scroll",
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35", path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
@@ -34,12 +43,13 @@ RSpec.describe "Analytics ingest", type: :request do
expect(response).to have_http_status(:accepted) expect(response).to have_http_status(:accepted)
body = JSON.parse(response.body) body = JSON.parse(response.body)
expect(body["accepted"]).to eq(3) expect(body["accepted"]).to eq(4)
expect(AnalyticsEvent.count).to eq(0) expect(AnalyticsEvent.count).to eq(0)
expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").pageview_count).to eq(1) expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").pageview_count).to eq(1)
expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").max_scroll_pct).to eq(80) expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").max_scroll_pct).to eq(80)
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:click_count)).to eq(1) expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:click_count)).to eq(1)
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
end end
it "rifiuta path esclusi" do it "rifiuta path esclusi" do