Compare commits

...
Author SHA1 Message Date
eminuxandCursor 70fccc493e Usa screenshot di pagina come sfondo heatmap al posto dell’iframe.
Cattura periodica client-side (con consenso), storage ActiveStorage e overlay allineato all’immagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:14:47 +02:00
eminuxandCursor 406a90ae5b Bumpe site-analytics.js a v=3 per bypassare la cache NPM su collaudo.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:08:10 +02:00
eminuxandCursor 8e7bbdf8b2 Bumpe cache-bust della heatmap JS su collaudo (evita 404 NPM su ?v=1).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 23:07:07 +02:00
eminuxandCursor b84321346f 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>
2026-08-20 23:03:12 +02:00
eminuxandCursor 8a07c5d569 Aggiunge heatmaps first-party (click/scroll) con pagina admin Analytics.
Raccoglie eventi aggregati dietro consenso cookie, senza PII né tracking su admin/regia/live/replay; GA4 resta invariato.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 22:50:15 +02:00
eminuxandCursor cc96c0396a Migliora sessioni admin e rende robusto il logout web.
Aggiunge filtri/colonne (società, orari) e dettaglio leggibile; evita 422 CSRF su logout e non cancella le cover slate custom in sync prod.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 20:41:12 +02:00
eminuxandCursor 79c48b226e Copia AAB/APK di release in native/android/dist con nome versionato.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:53:53 +02:00
eminuxandCursor c9b75065bc Bump Android a 2.0.12-native (versionCode 33) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:50:20 +02:00
eminuxandCursor 4ca9f3af8b Merge branch 'collaudo': avvisi multilanguage e allineamento sessioni admin.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:45:46 +02:00
eminuxandCursor f02782d0f0 Aggiunge traduzioni it/en/fr/de/es agli avvisi e toglie gli override Android/iOS.
Un avviso ha un testo per lingua; per differenziare le piattaforme si creano due avvisi. L'app riceve già il copy risolto da Accept-Language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:26:30 +02:00
eminuxandCursor fb82b74e92 Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:24:18 +02:00
eminuxandCursor 60dfe7a75b Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:21:03 +02:00
eminuxandCursor a07f231417 Permette titolo, testo e link diversi per Android e iOS negli avvisi.
Separa i canali app e mostra il nodo ingest nelle sessioni admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:09:04 +02:00
eminuxandCursor 43d5003de8 Completa UI e test del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:39:11 +02:00
eminuxandCursor eadfc29da3 Corregge YAML form contatti: quota form_lead con due punti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:37:45 +02:00
eminuxandCursor 50d3e2cf16 Completa traduzioni privacy, mailer e flash del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:52 +02:00
eminuxandCursor 55e71ca838 Allinea privacy DE/ES/FR al form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:07 +02:00
eminuxandCursor 5b723bf844 Aggiunge annunci in-app/sito, contatti e migliora UI copertina sul portale.
Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:30:49 +02:00
eminuxandCursor e502844b88 fix: non bloccare go-live se agent slate Hetzner è down
Se il push della cover custom all'agent fallisce (timeout/connection refused),
usa lo slate di default invece di abortire la creazione sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:26:52 +02:00
136 changed files with 6201 additions and 135 deletions
@@ -0,0 +1,101 @@
# frozen_string_literal: true
module Admin
class AnalyticsController < Admin::BaseController
def index
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence
}
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to])
cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
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)
scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples)
scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct)
max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct)
paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq
@pages = paths.map do |path|
samples = scroll_samples_by_path[path].to_i
{
page_path: path,
pageviews: pageviews_by_path[path].to_i,
clicks: clicks_by_path[path].to_i,
moves: moves_by_path[path].to_i,
scroll_samples: samples,
scroll_sum: scroll_sum_by_path[path].to_i,
max_scroll: max_scroll_by_path[path].to_i
}
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
end
def show
@page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence,
layer: params[:layer].to_s
}
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])
@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 = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct)
@max_scroll = stats.maximum(:max_scroll_pct).to_i
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
@grid = AnalyticsPageCell::GRID_SIZE
@snapshot = find_snapshot(@page_path, @filters[:device])
end
private
def parse_date(value)
return nil if value.blank?
Date.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
def find_snapshot(page_path, device)
scope = AnalyticsPageSnapshot.where(page_path: page_path)
if device.present? && AnalyticsEvent::DEVICES.include?(device)
snap = scope.find_by(device: device)
return snap if snap&.image&.attached?
end
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
end
end
end
@@ -0,0 +1,76 @@
module Admin
class AnnouncementsController < Admin::BaseController
before_action :set_announcement, only: %i[edit update destroy]
def index
@announcements = AppAnnouncement.newest
end
def new
@announcement = AppAnnouncement.new(
kind: "info",
severity: "info",
status: "draft",
show_on_android: true,
show_on_ios: true,
show_on_web_public: true,
show_on_web_private: true,
dismissible: true
)
end
def create
@announcement = AppAnnouncement.new(announcement_params)
@announcement.created_by_admin = current_admin_account
if @announcement.save
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @announcement.update(announcement_params)
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
@announcement.destroy!
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
end
private
def set_announcement
@announcement = AppAnnouncement.find(params[:id])
end
def announcement_params
params.require(:app_announcement).permit(
:kind, :severity, :status, :title, :body,
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
:show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
translations: {
en: AppAnnouncement::COPY_KEYS,
fr: AppAnnouncement::COPY_KEYS,
de: AppAnnouncement::COPY_KEYS,
es: AppAnnouncement::COPY_KEYS
}
).tap do |permitted|
%i[dismissible show_on_android show_on_ios show_on_web_public show_on_web_private].each do |key|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
end
%i[starts_at ends_at action_url action_label].each do |key|
permitted[key] = nil if permitted[key].blank?
end
end
end
end
end
@@ -1,6 +1,8 @@
module Admin
class AuthController < Admin::BaseController
skip_before_action :require_admin_login, only: %i[new create]
# Same as public logout: tolerate stale CSRF after long-lived admin tabs.
skip_before_action :verify_authenticity_token, only: :destroy
def new
redirect_to admin_root_path if admin_logged_in?
@@ -5,7 +5,7 @@ module Admin
@host = HostMetrics.new.sample!
@active_sessions = StreamSession
.where(status: DashboardStats::ACTIVE_STATUSES)
.includes(match: :team)
.includes(:stream_node, match: :team)
.order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8)
end
@@ -1,12 +1,23 @@
module Admin
class SessionsController < Admin::BaseController
def index
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50)
@filters = session_filters
scope = filtered_sessions
@total_count = scope.count
@sessions = scope
.includes(:stream_node, :user, match: { team: :club })
.order(Arel.sql("COALESCE(stream_sessions.started_at, stream_sessions.created_at) DESC"))
.limit(100)
@clubs = Club.order(:name)
@nodes = StreamNode.order(:slug)
end
def show
@session = StreamSession.find(params[:id])
@events = @session.stream_events.recent.limit(50)
@session = StreamSession
.includes(:stream_node, :user, :device_states, :recording, match: { team: :club })
.find(params[:id])
@events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club
end
def stop
@@ -39,5 +50,66 @@ module Admin
regia_expires_at: @session.regia_token_expires_at&.iso8601
}
end
private
def session_filters
{
q: params[:q].to_s.strip.presence,
status: params[:status].to_s.strip.presence,
platform: params[:platform].to_s.strip.presence,
club_id: params[:club_id].to_s.strip.presence,
stream_node_id: params[:stream_node_id].to_s.strip.presence,
from: params[:from].to_s.strip.presence,
to: params[:to].to_s.strip.presence
}
end
def filtered_sessions
scope = StreamSession.left_outer_joins(match: { team: :club })
if @filters[:q].present?
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
scope = scope.where(
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
term: term
)
end
if @filters[:status].present? && StreamSession::STATUSES.include?(@filters[:status])
scope = scope.where(stream_sessions: { status: @filters[:status] })
end
if @filters[:platform].present? && StreamSession::PLATFORMS.include?(@filters[:platform])
scope = scope.where(stream_sessions: { platform: @filters[:platform] })
end
if @filters[:club_id].present?
scope = scope.where(teams: { club_id: @filters[:club_id] })
end
if @filters[:stream_node_id].present?
scope = scope.where(stream_sessions: { stream_node_id: @filters[:stream_node_id] })
end
if (from_time = parse_filter_date(@filters[:from], end_of_day: false))
scope = scope.where(
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) >= ?",
from_time
)
end
if (to_time = parse_filter_date(@filters[:to], end_of_day: true))
scope = scope.where(
"COALESCE(stream_sessions.started_at, stream_sessions.created_at) <= ?",
to_time
)
end
scope
end
def parse_filter_date(value, end_of_day:)
return nil if value.blank?
date = Date.parse(value)
end_of_day ? date.end_of_day : date.beginning_of_day
rescue ArgumentError, TypeError
nil
end
end
end
@@ -0,0 +1,35 @@
# frozen_string_literal: true
module Analytics
class EventsController < ActionController::API
def create
payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
if result.rate_limited
return head :too_many_requests
end
render json: { accepted: result.accepted, rejected: result.rejected }, status: :accepted
end
private
def parse_payload
body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
return body if body.is_a?(Array)
raw = request.raw_post
return [] if raw.blank?
parsed = JSON.parse(raw)
return parsed["events"] if parsed.is_a?(Hash) && parsed["events"].is_a?(Array)
return parsed if parsed.is_a?(Array)
[]
rescue JSON::ParserError
[]
end
end
end
@@ -0,0 +1,21 @@
# frozen_string_literal: true
module Analytics
class SnapshotsController < ActionController::API
def create
result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path],
device: params[:device],
width: params[:width],
height: params[:height],
image: params[:image],
remote_ip: request.remote_ip
).call
return head :too_many_requests if result.rate_limited
return render json: { ok: false, error: result.error }, status: :unprocessable_content unless result.ok
render json: { ok: true, skipped: result.skipped }, status: :accepted
end
end
end
@@ -0,0 +1,13 @@
module Api
module V1
class AnnouncementsController < BaseController
skip_before_action :authenticate_request!
def index
platform = params[:platform].to_s.presence
announcements = AppAnnouncement.for_platform(platform).newest
render json: announcements.map { |item| item.as_api_json(platform: platform) }
end
end
end
end
@@ -0,0 +1,46 @@
module Public
class ContactsController < WebBaseController
def show
@inquiry = empty_inquiry
end
def create
result = Contacts::SubmitInquiry.call(params: inquiry_params, ip: request.remote_ip)
if result.ok
redirect_to public_contatti_path, notice: t("flash.contacts.sent")
return
end
@inquiry = inquiry_params
flash.now[:alert] = t("flash.contacts.#{result.error}")
render :show, status: result.error == :throttled ? :too_many_requests : :unprocessable_entity
end
private
def inquiry_params
{
name: params[:name].to_s,
email: params[:email].to_s,
club_name: params[:club_name].to_s,
topic: params[:topic].to_s,
message: params[:message].to_s,
website: params[:website].to_s,
accept_privacy: params[:accept_privacy].to_s
}
end
def empty_inquiry
{
name: "",
email: "",
club_name: "",
topic: "commercial",
message: "",
website: "",
accept_privacy: ""
}
end
end
end
@@ -1,5 +1,9 @@
module Public
class SessionsController < WebBaseController
# Logout must succeed even with a stale CSRF token (old tab after deploy /
# session rotate). SameSite=Lax already blocks cross-site cookie POSTs.
skip_before_action :verify_authenticity_token, only: :destroy
def new
if logged_in? && current_user.primary_club
redirect_to public_club_path(current_user.primary_club)
@@ -9,6 +9,7 @@ module Public
protect_from_forgery with: :exception
before_action :set_site_locale
before_action :load_site_announcements
private
@@ -40,5 +41,27 @@ module Public
def logged_in?
current_user.present?
end
PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members
].freeze
def load_site_announcements
@site_announcements = if skip_site_announcements?
AppAnnouncement.none
else
channel = private_web_area? ? :web_private : :web_public
AppAnnouncement.for_channel(channel).newest
end
end
def skip_site_announcements?
controller_name == "regia"
end
def private_web_area?
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
end
end
end
@@ -8,6 +8,7 @@ module Public
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
+109
View File
@@ -30,6 +30,88 @@ module AdminHelper
links
end
def admin_session_ingest_badge_class(node)
case node.role
when "home" then "badge--ingest-home"
when "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud"
else "badge--paused"
end
end
def admin_session_ingest_role_label(node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
end
def admin_session_status_badge_class(status)
case status.to_s
when "live" then "badge--live"
when "connecting", "reconnecting" then "badge--connecting"
when "paused", "idle" then "badge--paused"
when "ended" then "badge--ended"
when "error" then "badge--error"
else "badge--paused"
end
end
def admin_datetime(time, with_seconds: false)
return I18n.t("admin.common.dash") if time.blank?
format = with_seconds ? "%d/%m/%Y %H:%M:%S" : "%d/%m/%Y %H:%M"
time.in_time_zone.strftime(format)
end
def admin_session_duration_label(session)
secs = session.total_duration_secs.to_i
if secs <= 0 && session.started_at
end_time = session.ended_at || (session.terminal? ? session.updated_at : Time.current)
secs = (end_time - session.started_at).to_i if end_time
end
return I18n.t("admin.common.dash") if secs <= 0
hours = secs / 3600
mins = (secs % 3600) / 60
rem = secs % 60
if hours.positive?
format("%dh %02dm %02ds", hours, mins, rem)
elsif mins.positive?
format("%dm %02ds", mins, rem)
else
"#{rem}s"
end
end
def admin_format_event_meta(metadata)
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if metadata.blank?
pairs = metadata.to_h
return content_tag(:span, I18n.t("admin.common.dash"), class: "muted") if pairs.empty?
content_tag(:dl, class: "admin-event-meta") do
safe_join(
pairs.map do |key, value|
content_tag(:div, class: "admin-event-meta__row") do
content_tag(:dt, key.to_s) +
content_tag(:dd, admin_event_meta_value(value))
end
end
)
end
end
def admin_event_meta_value(value)
case value
when Hash, Array
content_tag(:code, value.to_json)
when TrueClass, FalseClass
value ? "true" : "false"
when NilClass
I18n.t("admin.common.dash")
else
value.to_s
end
end
def admin_regia_expires_label(iso_time)
return if iso_time.blank?
@@ -37,4 +119,31 @@ module AdminHelper
rescue ArgumentError, TypeError
nil
end
def announcement_status_badge(item)
case item.status
when "published" then item.severity == "critical" ? "live" : "ready"
when "archived" then "paused"
else "connecting"
end
end
def announcement_window_label(item)
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
if start_label && end_label
"#{start_label}#{end_label}"
elsif start_label
I18n.t("admin.announcements.window.from", time: start_label)
elsif end_label
I18n.t("admin.announcements.window.until", time: end_label)
else
I18n.t("admin.announcements.window.always")
end
end
def announcement_channels_label(item)
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
module LegalHelper
def legal_last_updated
"3 giugno 2026"
"20 agosto 2026"
end
def cookie_policy_last_updated
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Analytics
class AggregateJob < ApplicationJob
queue_as :default
def perform(purge: false)
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
lock_key = "analytics:aggregate:lock"
acquired = redis.set(lock_key, "1", nx: true, ex: 120)
return unless acquired
begin
service = Analytics::Aggregate.new
service.call
service.purge_old! if purge
ensure
redis.del(lock_key)
end
rescue StandardError => e
Rails.logger.error("[analytics] aggregate failed: #{e.class} #{e.message}")
raise
end
end
end
+25
View File
@@ -0,0 +1,25 @@
class ContactMailer < ApplicationMailer
def inquiry(name:, email:, club_name:, topic:, message:)
@name = name
@email = email
@club_name = club_name.presence
@topic = topic
@message = message
mail(
to: recipient_for(topic),
reply_to: email,
subject: t("mailers.contact.subject.#{topic}", name: name)
)
end
private
def recipient_for(topic)
if topic == "privacy"
MatchLiveTv.privacy_controller_email
else
MatchLiveTv.support_email
end
end
end
+1
View File
@@ -2,6 +2,7 @@ class AdminAccount < ApplicationRecord
include PasswordComplexity
has_secure_password
has_many :app_announcements, foreign_key: :created_by_admin_id, dependent: :nullify, inverse_of: :created_by_admin
validates :username, presence: true, uniqueness: true
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class AnalyticsEvent < ApplicationRecord
self.table_name = "analytics_events"
EVENT_TYPES = %w[click scroll pageview move].freeze
DEVICES = %w[mobile tablet desktop].freeze
validates :page_path, presence: true
validates :device, inclusion: { in: DEVICES }
validates :event_type, inclusion: { in: EVENT_TYPES }
validates :occurred_at, presence: true
validates :weight, numericality: { greater_than: 0, less_than_or_equal_to: 500 }
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
class AnalyticsPageCell < ApplicationRecord
self.table_name = "analytics_page_cells"
GRID_SIZE = 40
validates :day, :page_path, :device, :cell_x, :cell_y, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
validates :cell_x, :cell_y, inclusion: { in: 0...(GRID_SIZE) }
validates :click_count, numericality: { greater_than_or_equal_to: 0 }
validates :move_count, numericality: { greater_than_or_equal_to: 0 }
end
@@ -0,0 +1,12 @@
# frozen_string_literal: true
class AnalyticsPageSnapshot < ApplicationRecord
self.table_name = "analytics_page_snapshots"
has_one_attached :image
validates :page_path, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
validates :width, :height, numericality: { greater_than: 0 }
validates :captured_at, presence: true
end
+14
View File
@@ -0,0 +1,14 @@
# frozen_string_literal: true
class AnalyticsPageStat < ApplicationRecord
self.table_name = "analytics_page_stats"
validates :day, :page_path, :device, presence: true
validates :device, inclusion: { in: AnalyticsEvent::DEVICES }
def avg_scroll_pct
return 0 if scroll_samples.to_i <= 0
(scroll_sum_pct.to_f / scroll_samples).round
end
end
+240
View File
@@ -0,0 +1,240 @@
class AppAnnouncement < ApplicationRecord
KINDS = %w[info maintenance update].freeze
SEVERITIES = %w[info warning critical].freeze
STATUSES = %w[draft published archived].freeze
CHANNELS = {
android: :show_on_android,
ios: :show_on_ios,
web_public: :show_on_web_public,
web_private: :show_on_web_private
}.freeze
COPY_KEYS = %w[title body action_url action_label].freeze
URL_KEYS = %w[action_url].freeze
TITLE_KEYS = %w[title].freeze
BODY_KEYS = %w[body].freeze
LABEL_KEYS = %w[action_label].freeze
LOCALE_FALLBACKS = {
"it" => %w[it],
"en" => %w[en it],
"fr" => %w[fr en it],
"de" => %w[de en it],
"es" => %w[es en it]
}.freeze
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
validates :kind, :severity, :status, :title, :body, presence: true
validates :kind, inclusion: { in: KINDS }
validates :severity, inclusion: { in: SEVERITIES }
validates :status, inclusion: { in: STATUSES }
validates :title, length: { maximum: 120 }, allow_blank: true
validates :body, length: { maximum: 2000 }, allow_blank: true
validates :action_label, length: { maximum: 40 }, allow_blank: true
URL_KEYS.each do |field|
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
end
validate :ends_at_after_starts_at
validate :at_least_one_channel
validate :translations_are_valid
before_validation :normalize_translations
scope :newest, -> { order(created_at: :desc) }
scope :published, -> { where(status: "published") }
scope :active_now, lambda {
now = Time.current
published
.where("starts_at IS NULL OR starts_at <= ?", now)
.where("ends_at IS NULL OR ends_at > ?", now)
}
def self.for_channel(channel)
column = CHANNELS[channel.to_sym]
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
active_now.where(column => true)
end
def self.for_app
active_now.where("show_on_android = TRUE OR show_on_ios = TRUE")
end
def self.for_platform(platform)
case platform.to_s
when "android" then for_channel(:android)
when "ios" then for_channel(:ios)
else for_app
end
end
def published?
status == "published"
end
def selected_channels
CHANNELS.each_with_object([]) do |(key, column), keys|
keys << key if public_send(column)
end
end
def copy_values_for(locale)
loc = locale.to_s
if default_locale?(loc)
COPY_KEYS.index_with { |key| public_send(key).to_s.presence }
else
translation_hash(loc)
end
end
def filled_locale_codes
LocaleResolver.available.select { |loc| locale_has_copy?(loc) }.map(&:to_s)
end
def as_api_json(platform: nil, locale: I18n.locale)
{
id: id,
kind: kind,
severity: severity,
title: copy_for("title", locale: locale),
body: copy_for("body", locale: locale),
dismissible: dismissible,
action_url: resolved_action_url(platform, locale: locale),
action_label: copy_for("action_label", locale: locale).presence,
starts_at: starts_at&.iso8601,
ends_at: ends_at&.iso8601
}
end
def localized_title(locale: I18n.locale)
copy_for("title", locale: locale)
end
def localized_body(locale: I18n.locale)
copy_for("body", locale: locale)
end
def localized_action_label(locale: I18n.locale)
copy_for("action_label", locale: locale)
end
def resolved_action_url(platform, locale: I18n.locale)
url = copy_for("action_url", locale: locale)
return url if url.present?
return nil unless kind == "update"
case platform.to_s
when "android" then MatchLiveTv.play_store_url
when "ios" then MatchLiveTv.app_store_url
end
end
private
def copy_for(field, locale: I18n.locale)
field = field.to_s
locale_chain(locale).each do |loc|
value = value_for(loc, field)
return value if value.present?
end
nil
end
def value_for(locale, key)
copy_values_for(locale)[key].presence
end
def translation_hash(locale)
raw = translations.is_a?(Hash) ? translations : {}
values = raw.stringify_keys[locale.to_s]
return {} unless values.is_a?(Hash)
values.stringify_keys
end
def locale_chain(locale)
loc = (LocaleResolver.normalize(locale) || I18n.default_locale).to_s
LOCALE_FALLBACKS[loc] || [loc, I18n.default_locale.to_s].uniq
end
def locale_has_copy?(locale)
values = copy_values_for(locale)
values["title"].present? || values["body"].present?
end
def default_locale?(locale)
locale.to_s == I18n.default_locale.to_s
end
def normalize_translations
self.translations = self.class.sanitize_translations(translations)
end
def self.sanitize_translations(value)
raw = coerce_translation_hash(value)
LocaleResolver.available.each_with_object({}) do |loc, acc|
next if loc.to_s == I18n.default_locale.to_s
payload = raw[loc.to_s]
next unless payload.is_a?(Hash)
cleaned = COPY_KEYS.each_with_object({}) do |key, fields|
text = payload[key].to_s.strip
fields[key] = text if text.present?
end
acc[loc.to_s] = cleaned if cleaned.any?
end
end
def self.coerce_translation_hash(value)
hash = if value.is_a?(ActionController::Parameters)
value.to_unsafe_h
elsif value.is_a?(Hash)
value
else
{}
end
hash.deep_stringify_keys
end
private_class_method :coerce_translation_hash
def at_least_one_channel
return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private?
errors.add(:base, :no_channel)
end
def ends_at_after_starts_at
return if starts_at.blank? || ends_at.blank?
return if ends_at > starts_at
errors.add(:ends_at, :invalid)
end
def translations_are_valid
translation_entries.each do |locale, payload|
TITLE_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 120
end
BODY_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 2000
end
LABEL_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 40
end
URL_KEYS.each do |key|
url = payload[key].to_s
next if url.blank? || url.match?(/\Ahttps?:\/\/.+/i)
errors.add(:base, :translation_invalid_url, locale: locale)
end
end
end
def translation_entries
raw = translations.is_a?(Hash) ? translations : {}
raw.stringify_keys.filter_map do |locale, payload|
next unless payload.is_a?(Hash)
[locale, payload.stringify_keys]
end
end
end
+1
View File
@@ -10,6 +10,7 @@ class StreamSession < ApplicationRecord
belongs_to :stream_node, optional: true
has_many :stream_events, dependent: :destroy
has_one :score_state, dependent: :destroy
has_one :recording
has_many :device_states, dependent: :destroy
validates :platform, inclusion: { in: PLATFORMS }
@@ -0,0 +1,90 @@
# frozen_string_literal: true
module Analytics
class Aggregate
BATCH = 500
def call
loop do
ids = AnalyticsEvent.order(:occurred_at).limit(BATCH).pluck(:id)
break if ids.empty?
events = AnalyticsEvent.where(id: ids).to_a
apply_points(events.select { |e| e.event_type == "click" }, :click_count)
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
end
end
def purge_old!(retention: 30.days)
AnalyticsEvent.where("occurred_at < ?", retention.ago).delete_all
AnalyticsPageCell.where("day < ?", retention.ago.to_date).delete_all
AnalyticsPageStat.where("day < ?", retention.ago.to_date).delete_all
AnalyticsPageSnapshot.where("captured_at < ?", retention.ago).find_each do |snap|
snap.image.purge if snap.image.attached?
snap.destroy!
end
end
private
def apply_points(events, counter_attr)
return if events.empty?
grid = AnalyticsPageCell::GRID_SIZE
grouped = Hash.new(0)
events.each do |e|
next if e.x_pct.nil? || e.y_pct.nil?
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
key = [e.occurred_at.in_time_zone.to_date, e.page_path, e.device, cell_x, cell_y]
grouped[key] += e.weight.to_i.clamp(1, 500)
end
now = Time.current
grouped.each do |(day, page_path, device, cell_x, cell_y), count|
cell = AnalyticsPageCell.find_or_initialize_by(
day: day, page_path: page_path, device: device, cell_x: cell_x, cell_y: cell_y
)
cell[counter_attr] = cell[counter_attr].to_i + count
cell.created_at ||= now
cell.updated_at = now
cell.save!
end
end
def apply_stats(events)
return if events.empty?
grouped = {}
events.each do |e|
day = e.occurred_at.in_time_zone.to_date
key = [day, e.page_path, e.device]
bucket = grouped[key] ||= { pageviews: 0, scroll_samples: 0, scroll_sum: 0, max_scroll: 0 }
case e.event_type
when "pageview"
bucket[:pageviews] += e.weight.to_i.clamp(1, 500)
when "scroll"
pct = e.scroll_pct.to_f.round
bucket[:scroll_samples] += 1
bucket[:scroll_sum] += pct
bucket[:max_scroll] = [bucket[:max_scroll], pct].max
end
end
now = Time.current
grouped.each do |(day, page_path, device), vals|
stat = AnalyticsPageStat.find_or_initialize_by(day: day, page_path: page_path, device: device)
stat.pageview_count = stat.pageview_count.to_i + vals[:pageviews]
stat.scroll_samples = stat.scroll_samples.to_i + vals[:scroll_samples]
stat.scroll_sum_pct = stat.scroll_sum_pct.to_i + vals[:scroll_sum]
stat.max_scroll_pct = [stat.max_scroll_pct.to_i, vals[:max_scroll]].max
stat.created_at ||= now
stat.updated_at = now
stat.save!
end
end
end
end
+126
View File
@@ -0,0 +1,126 @@
# frozen_string_literal: true
module Analytics
class Ingest
MAX_BATCH = 80
RATE_LIMIT_PER_MINUTE = 90
Result = Struct.new(:accepted, :rejected, :rate_limited, keyword_init: true)
def initialize(events:, remote_ip:)
@events = Array(events)
@remote_ip = remote_ip.to_s.presence || "unknown"
end
def call
return Result.new(accepted: 0, rejected: 0, rate_limited: true) if rate_limited?
slice = @events.first(MAX_BATCH)
accepted = 0
rejected = 0
rows = []
slice.each do |raw|
attrs = build_attrs(raw)
if attrs
rows << attrs
accepted += 1
else
rejected += 1
end
end
AnalyticsEvent.insert_all(rows) if rows.any?
Analytics::Aggregate.new.call if rows.any?
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
end
private
def rate_limited?
key = "analytics:ingest:#{@remote_ip}"
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
count = redis.incr(key)
redis.expire(key, 60) if count == 1
count > RATE_LIMIT_PER_MINUTE
rescue StandardError => e
Rails.logger.warn("[analytics] rate limit unavailable: #{e.class}")
false
end
def build_attrs(raw)
data = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
event_type = data[:type].to_s.presence || data[:event_type].to_s
return nil unless AnalyticsEvent::EVENT_TYPES.include?(event_type)
device = data[:device].to_s
return nil unless AnalyticsEvent::DEVICES.include?(device)
page_path = PathNormalizer.normalize(data[:path] || data[:page_path])
return nil if PathNormalizer.excluded?(page_path)
return nil if page_path.length > 200
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 = {
page_path: page_path,
device: device,
event_type: event_type,
occurred_at: occurred_at,
weight: weight,
x_pct: nil,
y_pct: nil,
scroll_pct: nil,
created_at: Time.current,
updated_at: Time.current
}
case event_type
when "click", "move"
x = clamp_pct(data[:x] || data[:x_pct])
y = clamp_pct(data[:y] || data[:y_pct])
return nil if x.nil? || y.nil?
attrs[:x_pct] = x
attrs[:y_pct] = y
when "scroll"
scroll = clamp_pct(data[:scroll] || data[:scroll_pct])
return nil if scroll.nil?
attrs[:scroll_pct] = scroll
when "pageview"
# no extra fields
end
attrs
rescue ArgumentError, TypeError
nil
end
def clamp_pct(value)
return nil if value.nil?
n = Float(value)
return nil if n.nan? || n.infinite?
return nil if n.negative? || n > 100
n.round(2)
rescue ArgumentError, TypeError
nil
end
def parse_time(value)
return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
return Time.zone.at(value.to_i / 1000.0) if value.is_a?(Numeric) || value.to_s.match?(/\A\d{10,13}\z/)
Time.zone.parse(value.to_s)
rescue ArgumentError, TypeError
nil
end
end
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Analytics
class PathNormalizer
UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
NUMERIC_RE = /\A\d{6,}\z/
TOKENISH_RE = /\A[A-Za-z0-9_\-]{22,}\z/
EXCLUDED_PREFIXES = %w[/admin /regia /analytics /cable /api /webhooks /internal].freeze
class << self
def normalize(raw_path)
path = raw_path.to_s.split("?", 2).first.to_s
path = "/" if path.blank?
path = "/#{path}" unless path.start_with?("/")
path = path.gsub(%r{/{2,}}, "/").chomp("/")
path = "/" if path.blank?
parts = path.split("/").map do |segment|
next segment if segment.blank?
next ":id" if UUID_RE.match?(segment)
next ":id" if NUMERIC_RE.match?(segment)
next ":token" if TOKENISH_RE.match?(segment) && segment.match?(/[A-Z]/)
segment
end
normalized = parts.join("/")
normalized = "/" if normalized.blank?
normalized
end
def excluded?(raw_or_normalized)
path = normalize(raw_or_normalized)
return true if EXCLUDED_PREFIXES.any? { |p| path == p || path.start_with?("#{p}/") }
return true if path.match?(%r{\A/live/[^/]+})
return true if path.match?(%r{\A/replay/[^/]+})
false
end
end
end
end
@@ -0,0 +1,84 @@
# frozen_string_literal: true
module Analytics
class SnapshotIngest
MAX_BYTES = 1_800_000
MIN_REFRESH = 12.hours
RATE_LIMIT_PER_HOUR = 20
Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true)
def initialize(path:, device:, width:, height:, image:, remote_ip:)
@path = path
@device = device.to_s
@width = width
@height = height
@image = image
@remote_ip = remote_ip.to_s.presence || "unknown"
end
def call
return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited?
page_path = PathNormalizer.normalize(@path)
return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path)
return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device)
return Result.new(ok: false, skipped: true, error: "image") unless valid_image?
width = Integer(@width)
height = Integer(@height)
return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000
snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device)
if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached?
return Result.new(ok: true, skipped: true)
end
snap.width = width
snap.height = height
snap.captured_at = Time.current
snap.save!
snap.image.purge if snap.image.attached?
snap.image.attach(
io: image_io,
filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg",
content_type: "image/jpeg"
)
Result.new(ok: true, skipped: false)
rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e
Result.new(ok: false, skipped: true, error: e.class.name)
end
private
def rate_limited?
key = "analytics:snapshot:#{@remote_ip}"
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
count = redis.incr(key)
redis.expire(key, 3600) if count == 1
count > RATE_LIMIT_PER_HOUR
rescue StandardError => e
Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}")
false
end
def valid_image?
return false if @image.blank?
return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read)
return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES
content_type = @image.content_type.to_s if @image.respond_to?(:content_type)
content_type.blank? || content_type.start_with?("image/")
end
def image_io
if @image.respond_to?(:tempfile)
@image.tempfile.rewind
@image.tempfile
else
StringIO.new(@image.read)
end
end
end
end
@@ -0,0 +1,102 @@
module Contacts
class SubmitInquiry
TOPICS = %w[commercial support privacy].freeze
LIMIT = 5
WINDOW = 1.hour
NAME_MAX = 120
CLUB_MAX = 160
MESSAGE_MIN = 10
MESSAGE_MAX = 4_000
Result = Struct.new(:ok, :error, keyword_init: true)
def self.call(params:, ip:)
new(params: params, ip: ip).call
end
def initialize(params:, ip:)
@params = params
@ip = ip.to_s.presence || "unknown"
end
def call
if honeypot_filled?
record_attempt
return Result.new(ok: true, error: nil)
end
return Result.new(ok: false, error: :throttled) if throttled?
error = validate
return Result.new(ok: false, error: error) if error
record_attempt
deliver
Result.new(ok: true, error: nil)
end
private
def honeypot_filled?
@params[:website].to_s.strip.present?
end
def throttled?
current_count >= LIMIT
end
def validate
return :privacy_required unless @params[:accept_privacy].to_s == "1"
return :invalid if name.blank? || name.length > NAME_MAX
return :invalid_email if email.blank? || email !~ URI::MailTo::EMAIL_REGEXP
return :invalid if club_name.length > CLUB_MAX
return :invalid unless TOPICS.include?(topic)
return :invalid_message if message.length < MESSAGE_MIN || message.length > MESSAGE_MAX
nil
end
def deliver
mail = ContactMailer.inquiry(
name: name,
email: email,
club_name: club_name,
topic: topic,
message: message
)
MatchLiveTv.deliver_mail(mail)
end
def record_attempt
Rails.cache.write(cache_key, current_count + 1, expires_in: WINDOW, raw: true)
end
def current_count
Rails.cache.read(cache_key, raw: true).to_i
end
def cache_key
"contacts:inquiry:#{@ip}"
end
def name
@params[:name].to_s.strip
end
def email
@params[:email].to_s.strip.downcase
end
def club_name
@params[:club_name].to_s.strip
end
def topic
@params[:topic].to_s.strip
end
def message
@params[:message].to_s.strip
end
end
end
@@ -44,8 +44,9 @@ module Streams
end
@mediamtx_path = mediamtx_path
rescue Error => e
Rails.logger.warn("[SlateDistributor] session=#{@session.id} #{e.message}")
rescue StandardError => e
# Agent cloud down / timeout / disk missing: non bloccare il go-live, usa slate default.
Rails.logger.warn("[SlateDistributor] session=#{@session.id} #{e.class}: #{e.message}")
@mediamtx_path = default_slate_path
end
@@ -0,0 +1,72 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.analytics.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.from") %></span>
<%= date_field_tag :from, @filters[:from] %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.device") %></span>
<%= select_tag :device,
options_for_select(
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
@filters[:device]
) %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.analytics.filters.reset"), admin_analytics_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
</div>
<div class="panel">
<% if @pages.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.analytics.index.table.path") %></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.avg_scroll") %></th>
<th><%= t("admin.analytics.index.table.max_scroll") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @pages.each do |row| %>
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
<tr>
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
<td><%= row[:pageviews] %></td>
<td><%= row[:moves] %></td>
<td><%= row[:clicks] %></td>
<td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td>
<td>
<%= link_to t("admin.analytics.index.heatmap"),
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.analytics.index.none") %></p>
<% end %>
</div>
@@ -0,0 +1,138 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head admin-session-head">
<div>
<p class="muted admin-page-sub">
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
</p>
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
<p><code class="admin-mono"><%= @page_path %></code></p>
</div>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
<%= hidden_field_tag :page_path, @page_path %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.from") %></span>
<%= date_field_tag :from, @filters[:from] %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.device") %></span>
<%= select_tag :device,
options_for_select(
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
@filters[:device]
) %>
</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 class="admin-filter-actions">
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
</div>
<% end %>
</div>
<div class="admin-session-grid">
<section class="panel">
<h3><%= t("admin.analytics.show.summary") %></h3>
<dl class="admin-kv">
<div>
<dt><%= t("admin.analytics.index.table.pageviews") %></dt>
<dd><%= @pageviews %></dd>
</div>
<div>
<dt><%= t("admin.analytics.index.table.moves") %></dt>
<dd><%= @move_total %></dd>
</div>
<div>
<dt><%= t("admin.analytics.index.table.clicks") %></dt>
<dd><%= @click_total %></dd>
</div>
<div>
<dt><%= t("admin.analytics.index.table.avg_scroll") %></dt>
<dd><%= @avg_scroll %>%</dd>
</div>
<div>
<dt><%= t("admin.analytics.index.table.max_scroll") %></dt>
<dd><%= @max_scroll %>%</dd>
</div>
</dl>
</section>
<section class="panel" style="grid-column: span 2;">
<h3><%= t("admin.analytics.show.scroll_depth") %></h3>
<div class="admin-scroll-bar" aria-hidden="true">
<div class="admin-scroll-bar__avg" style="width: <%= @avg_scroll %>%"></div>
<div class="admin-scroll-bar__max" style="left: <%= @max_scroll %>%"></div>
</div>
<p class="muted admin-table-sub">
<%= t("admin.analytics.show.scroll_hint", avg: @avg_scroll, max: @max_scroll) %>
</p>
</section>
</div>
<section class="panel">
<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? %>
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
<% if @snapshot&.image&.attached? %>
<div
class="admin-heatmap-scaler"
id="admin-heatmap-scaler"
data-design-width="<%= [@snapshot.width, 1].max %>"
data-design-height="<%= [@snapshot.height, 1].max %>"
>
<%= image_tag url_for(@snapshot.image),
class: "admin-heatmap-shot",
id: "admin-heatmap-shot",
alt: t("admin.analytics.show.preview_title") %>
<canvas
id="admin-heatmap"
class="admin-heatmap-overlay"
data-grid="<%= @grid %>"
data-max="<%= [@max_weight, 1].max %>"
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
></canvas>
</div>
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
<%= t("admin.analytics.show.snapshot_meta",
device: @snapshot.device,
at: l(@snapshot.captured_at, format: :short)) %>
</p>
<% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
<canvas
id="admin-heatmap"
class="admin-heatmap-overlay"
data-grid="<%= @grid %>"
data-max="<%= [@max_weight, 1].max %>"
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
></canvas>
<% end %>
</div>
<script src="/admin-analytics-heatmap.js?v=4" defer></script>
<% else %>
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
<% end %>
</section>
@@ -0,0 +1,112 @@
<%= form_with model: announcement, url: (announcement.persisted? ? admin_announcement_path(announcement) : admin_announcements_path),
method: (announcement.persisted? ? :patch : :post), local: true, class: "admin-form" do |f| %>
<div class="admin-form-row">
<div>
<%= f.label :kind, t("admin.announcements.form.kind") %>
<%= f.select :kind, AppAnnouncement::KINDS.map { |k| [t("admin.announcements.kind.#{k}"), k] } %>
</div>
<div>
<%= f.label :severity, t("admin.announcements.form.severity") %>
<%= f.select :severity, AppAnnouncement::SEVERITIES.map { |k| [t("admin.announcements.severity.#{k}"), k] } %>
</div>
</div>
<div>
<%= f.label :status, t("admin.announcements.form.status") %>
<%= f.select :status, AppAnnouncement::STATUSES.map { |k| [t("admin.announcements.status.#{k}"), k] } %>
</div>
<fieldset class="admin-channels">
<legend><%= t("admin.announcements.form.channels") %></legend>
<label class="admin-checkbox">
<%= f.check_box :show_on_android %>
<span><%= t("admin.announcements.channels.android") %></span>
</label>
<label class="admin-checkbox">
<%= f.check_box :show_on_ios %>
<span><%= t("admin.announcements.channels.ios") %></span>
</label>
<label class="admin-checkbox">
<%= f.check_box :show_on_web_public %>
<span><%= t("admin.announcements.channels.web_public") %></span>
</label>
<label class="admin-checkbox">
<%= f.check_box :show_on_web_private %>
<span><%= t("admin.announcements.channels.web_private") %></span>
</label>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
</fieldset>
<fieldset class="admin-channels">
<legend><%= t("admin.announcements.form.locales_legend") %></legend>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.locales_hint") %></p>
<div class="admin-locale-tabs" role="tablist">
<% language_options.each do |opt| %>
<button type="button"
class="admin-locale-tab<%= " is-active" if opt[:code].to_s == I18n.default_locale.to_s %>"
data-locale-tab="<%= opt[:code] %>"
role="tab"
aria-selected="<%= opt[:code].to_s == I18n.default_locale.to_s %>">
<span aria-hidden="true"><%= opt[:flag] %></span>
<%= opt[:native] %>
<% if opt[:code].to_s == I18n.default_locale.to_s %>
<span class="muted">(<%= t("admin.announcements.form.locale_required") %>)</span>
<% elsif announcement.filled_locale_codes.include?(opt[:code].to_s) %>
<span class="muted"></span>
<% end %>
</button>
<% end %>
</div>
<% language_options.each do |opt| %>
<div class="admin-locale-panel"
data-locale-panel="<%= opt[:code] %>"
role="tabpanel"
<%= "hidden" unless opt[:code].to_s == I18n.default_locale.to_s %>>
<%= render "locale_copy", f: f, announcement: announcement, locale: opt[:code] %>
</div>
<% end %>
</fieldset>
<div class="admin-form-row">
<div>
<%= f.label :starts_at, t("admin.announcements.form.starts_at") %>
<%= f.datetime_field :starts_at, value: announcement.starts_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
</div>
<div>
<%= f.label :ends_at, t("admin.announcements.form.ends_at") %>
<%= f.datetime_field :ends_at, value: announcement.ends_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
</div>
</div>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
<label class="admin-checkbox">
<%= f.check_box :dismissible %>
<span><%= t("admin.announcements.form.dismissible") %></span>
</label>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.dismissible_hint") %></p>
<div class="admin-form-actions">
<%= f.submit (announcement.persisted? ? t("admin.announcements.form.submit_update") : t("admin.announcements.form.submit_create")), class: "admin-btn admin-btn--primary" %>
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
</div>
<% end %>
<script>
(function () {
var tabs = document.querySelectorAll("[data-locale-tab]");
if (!tabs.length) return;
tabs.forEach(function (btn) {
btn.addEventListener("click", function () {
var loc = btn.getAttribute("data-locale-tab");
tabs.forEach(function (other) {
var on = other === btn;
other.classList.toggle("is-active", on);
other.setAttribute("aria-selected", on ? "true" : "false");
});
document.querySelectorAll("[data-locale-panel]").forEach(function (panel) {
panel.hidden = panel.getAttribute("data-locale-panel") !== loc;
});
});
});
})();
</script>
@@ -0,0 +1,44 @@
<% it = locale.to_s == I18n.default_locale.to_s %>
<% values = announcement.copy_values_for(locale) %>
<% name_base = "app_announcement[translations][#{locale}]" %>
<div>
<% if it %>
<%= f.label :title, t("admin.announcements.form.title") %>
<%= f.text_field :title, required: true, maxlength: 120, id: "ann_#{locale}_title" %>
<% else %>
<label for="ann_<%= locale %>_title"><%= t("admin.announcements.form.title") %></label>
<%= text_field_tag "#{name_base}[title]", values["title"], maxlength: 120, id: "ann_#{locale}_title" %>
<% end %>
</div>
<div>
<% if it %>
<%= f.label :body, t("admin.announcements.form.body") %>
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
<% else %>
<label for="ann_<%= locale %>_body"><%= t("admin.announcements.form.body") %></label>
<%= text_area_tag "#{name_base}[body]", values["body"], rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
<% end %>
</div>
<div>
<% if it %>
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
<%= f.url_field :action_url, placeholder: "https://", id: "ann_#{locale}_action_url" %>
<% else %>
<label for="ann_<%= locale %>_action_url"><%= t("admin.announcements.form.action_url") %></label>
<%= url_field_tag "#{name_base}[action_url]", values["action_url"], placeholder: "https://", id: "ann_#{locale}_action_url" %>
<% end %>
</div>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
<div>
<% if it %>
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
<%= f.text_field :action_label, maxlength: 40, id: "ann_#{locale}_action_label" %>
<% else %>
<label for="ann_<%= locale %>_action_label"><%= t("admin.announcements.form.action_label") %></label>
<%= text_field_tag "#{name_base}[action_label]", values["action_label"], maxlength: 40, id: "ann_#{locale}_action_label" %>
<% end %>
</div>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.announcements.edit.title") %></h2>
</div>
<div class="panel">
<%= render "form", announcement: @announcement %>
</div>
@@ -0,0 +1,57 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.announcements.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.announcements.index.lead") %></p>
</div>
<div class="panel" style="margin-bottom:1.5rem">
<div class="admin-toolbar">
<%= link_to t("admin.announcements.index.new"), new_admin_announcement_path, class: "admin-btn admin-btn--primary" %>
</div>
</div>
<div class="panel">
<h2><%= t("admin.announcements.index.table_title") %></h2>
<% if @announcements.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.announcements.table.status") %></th>
<th><%= t("admin.announcements.table.kind") %></th>
<th><%= t("admin.announcements.table.title") %></th>
<th><%= t("admin.announcements.table.channels") %></th>
<th><%= t("admin.announcements.table.window") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @announcements.each do |item| %>
<tr>
<td>
<span class="badge badge--<%= announcement_status_badge(item) %>"><%= t("admin.announcements.status.#{item.status}") %></span>
</td>
<td class="muted"><%= t("admin.announcements.kind.#{item.kind}") %></td>
<td>
<strong><%= item.title %></strong>
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(item.body, length: 90) %></div>
<div class="muted" style="font-size:0.8rem;margin-top:0.2rem"><%= item.filled_locale_codes.map(&:upcase).join(" · ") %></div>
</td>
<td class="muted"><%= announcement_channels_label(item) %></td>
<td class="muted"><%= announcement_window_label(item) %></td>
<td class="admin-actions">
<%= link_to t("admin.announcements.actions.edit"), edit_admin_announcement_path(item), class: "admin-btn admin-btn--sm" %>
<%= button_to t("admin.announcements.actions.delete"), admin_announcement_path(item), method: :delete,
class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.announcements.actions.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.announcements.index.none") %></p>
<% end %>
</div>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.announcements.new.title") %></h2>
</div>
<div class="panel">
<%= render "form", announcement: @announcement %>
</div>
@@ -107,6 +107,7 @@
<tr>
<th><%= t("admin.dashboard.sessions.table.match") %></th>
<th><%= t("admin.dashboard.sessions.table.status") %></th>
<th><%= t("admin.dashboard.sessions.table.ingest") %></th>
<th><%= t("admin.dashboard.sessions.table.start") %></th>
<th><%= t("admin.dashboard.sessions.table.link") %></th>
<th></th>
@@ -117,12 +118,15 @@
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
<td>
<div class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</div>
</td>
<td class="admin-actions">
<%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %>
@@ -0,0 +1,9 @@
<% node = session.stream_node %>
<% if node %>
<div class="admin-ingest">
<code class="admin-ingest__slug"><%= node.slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span>
</div>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %>
+130 -27
View File
@@ -1,28 +1,131 @@
<h2><%= t("admin.sessions.index.title") %></h2>
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.sessions.index.table.match") %></th>
<th><%= t("admin.sessions.index.table.status") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @sessions.each do |s| %>
<tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
<td><%= s.disconnection_count %></td>
<td class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.sessions.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.sessions.index.lead") %></p>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_sessions_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.q") %></span>
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.sessions.index.filters.q_placeholder") %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.status") %></span>
<%= select_tag :status,
options_for_select(
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::STATUSES.map { |s| [s, s] },
@filters[:status]
) %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.platform") %></span>
<%= select_tag :platform,
options_for_select(
[[t("admin.sessions.index.filters.any"), ""]] + StreamSession::PLATFORMS.map { |p| [p, p] },
@filters[:platform]
) %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.club") %></span>
<%= select_tag :club_id,
options_for_select(
[[t("admin.sessions.index.filters.any"), ""]] + @clubs.map { |c| [c.name, c.id] },
@filters[:club_id]
) %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.node") %></span>
<%= select_tag :stream_node_id,
options_for_select(
[[t("admin.sessions.index.filters.any"), ""]] + @nodes.map { |n| ["#{n.slug} (#{n.role})", n.id] },
@filters[:stream_node_id]
) %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.from") %></span>
<%= date_field_tag :from, @filters[:from] %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.sessions.index.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.sessions.index.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.sessions.index.filters.reset"), admin_sessions_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
<span class="muted admin-filter-count">
<%= t("admin.sessions.index.results", shown: @sessions.size, total: @total_count) %>
</span>
</div>
<% end %>
</div>
<div class="panel">
<% if @sessions.any? %>
<div class="admin-table-wrap">
<table class="admin-table admin-table--sessions">
<thead>
<tr>
<th><%= t("admin.sessions.index.table.club") %></th>
<th><%= t("admin.sessions.index.table.match") %></th>
<th><%= t("admin.sessions.index.table.status") %></th>
<th><%= t("admin.sessions.index.table.started") %></th>
<th><%= t("admin.sessions.index.table.ended") %></th>
<th><%= t("admin.sessions.index.table.duration") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @sessions.each do |s| %>
<% club = s.match.team.club %>
<tr>
<td>
<% if club %>
<%= link_to club.name, admin_club_path(club), class: "admin-table-strong" %>
<% else %>
<span class="muted"><%= t("admin.common.dash") %></span>
<% end %>
</td>
<td>
<div class="admin-table-strong"><%= s.match.team.name %> vs <%= s.match.opponent_name %></div>
<div class="muted admin-table-sub"><%= s.platform %></div>
</td>
<td>
<span class="badge <%= admin_session_status_badge_class(s.status) %>"><%= s.status %></span>
</td>
<td class="muted"><%= admin_datetime(s.started_at || s.created_at) %></td>
<td class="muted"><%= s.ended_at ? admin_datetime(s.ended_at) : t("admin.common.dash") %></td>
<td class="muted"><%= admin_session_duration_label(s) %></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td><%= s.disconnection_count %></td>
<td>
<div class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %>
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</div>
</td>
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
</tr>
<% end %>
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</td>
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
</tr>
<% end %>
</tbody>
</table>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.sessions.index.none") %></p>
<% end %>
</div>
+242 -34
View File
@@ -1,39 +1,247 @@
<h2><%= t("admin.sessions.show.title", id: @session.id) %></h2>
<p><%= t("admin.sessions.show.status_label") %> <strong><%= @session.status %></strong></p>
<% unless @session.terminal? %>
<p>
<%= button_to t("admin.sessions.show.stop_button"),
stop_admin_session_path(@session),
method: :post,
class: "admin-btn admin-btn--danger",
form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %>
</p>
<% end %>
<p><%= t("admin.sessions.show.match_label", team: @session.match.team.name, opponent: @session.match.opponent_name) %></p>
<% content_for :body_class, "admin-body" %>
<% team = @session.match.team %>
<% club = @club || team.club %>
<div class="admin-page-head admin-session-head">
<div>
<p class="muted admin-page-sub">
<%= link_to t("admin.sessions.show.back"), admin_sessions_path %>
</p>
<h2 class="admin-page-title"><%= t("admin.sessions.show.heading") %></h2>
<p class="admin-session-match">
<strong><%= team.name %></strong>
<span class="muted">vs</span>
<strong><%= @session.match.opponent_name %></strong>
</p>
<% if club %>
<p class="muted">
<%= t("admin.sessions.show.club_label") %>
<%= link_to club.name, admin_club_path(club) %>
</p>
<% end %>
</div>
<div class="admin-session-head-actions">
<span class="badge <%= admin_session_status_badge_class(@session.status) %>"><%= @session.status %></span>
<% unless @session.terminal? %>
<%= button_to t("admin.sessions.show.stop_button"),
stop_admin_session_path(@session),
method: :post,
class: "admin-btn admin-btn--danger admin-btn--sm",
form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %>
<% end %>
</div>
</div>
<div class="admin-session-grid">
<section class="panel">
<h3><%= t("admin.sessions.show.summary_title") %></h3>
<dl class="admin-kv">
<div>
<dt><%= t("admin.sessions.show.fields.id") %></dt>
<dd><code class="admin-mono"><%= @session.id %></code></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.club") %></dt>
<dd><%= club&.name || t("admin.common.dash") %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.team") %></dt>
<dd><%= team.name %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.opponent") %></dt>
<dd><%= @session.match.opponent_name %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.operator") %></dt>
<dd>
<% if @session.user %>
<%= @session.user.name.presence || @session.user.email %>
<span class="muted">(<%= @session.user.email %>)</span>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.platform") %></dt>
<dd><%= @session.platform %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.privacy") %></dt>
<dd><%= @session.privacy_status %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.quality") %></dt>
<dd><%= @session.quality_preset %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.min_quality") %></dt>
<dd><%= @session.min_quality_preset %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.audio") %></dt>
<dd><%= @session.audio_muted? ? t("admin.sessions.show.audio_muted") : t("admin.sessions.show.audio_on") %></dd>
</div>
</dl>
</section>
<section class="panel">
<h3><%= t("admin.sessions.show.timing_title") %></h3>
<dl class="admin-kv">
<div>
<dt><%= t("admin.sessions.show.fields.created") %></dt>
<dd><%= admin_datetime(@session.created_at, with_seconds: true) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.started") %></dt>
<dd><%= admin_datetime(@session.started_at, with_seconds: true) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.ended") %></dt>
<dd><%= admin_datetime(@session.ended_at, with_seconds: true) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.duration") %></dt>
<dd><%= admin_session_duration_label(@session) %></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.disconnects") %></dt>
<dd><%= @session.disconnection_count %></dd>
</div>
<% if @session.match.scheduled_at %>
<div>
<dt><%= t("admin.sessions.show.fields.scheduled") %></dt>
<dd><%= admin_datetime(@session.match.scheduled_at) %></dd>
</div>
<% end %>
<% if @session.match.location.present? %>
<div>
<dt><%= t("admin.sessions.show.fields.location") %></dt>
<dd><%= @session.match.location %></dd>
</div>
<% end %>
</dl>
</section>
<section class="panel">
<h3><%= t("admin.sessions.show.ingest_title") %></h3>
<dl class="admin-kv">
<div>
<dt><%= t("admin.sessions.show.fields.node") %></dt>
<dd>
<% if @session.stream_node %>
<code><%= @session.stream_node.slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>">
<%= admin_session_ingest_role_label(@session.stream_node) %>
</span>
<span class="muted">(<%= @session.stream_node.provider %>)</span>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %>
</dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.rtmp") %></dt>
<dd><code class="admin-mono admin-mono--wrap"><%= @session.rtmp_ingest_url %></code></dd>
</div>
<div>
<dt><%= t("admin.sessions.show.fields.hls") %></dt>
<dd><code class="admin-mono admin-mono--wrap"><%= @session.hls_playback_url %></code></dd>
</div>
<% if @session.youtube_broadcast_id.present? %>
<div>
<dt><%= t("admin.sessions.show.youtube_studio") %></dt>
<dd>
<a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener">
<%= t("admin.sessions.show.broadcast") %>
</a>
<span class="muted">(<%= @session.youtube_broadcast_id %>)</span>
</dd>
</div>
<% end %>
<% if @session.recording %>
<div>
<dt><%= t("admin.sessions.show.fields.recording") %></dt>
<dd>
<span class="badge badge--<%= @session.recording.status == 'ready' ? 'ready' : 'paused' %>">
<%= @session.recording.status %>
</span>
<% if club %>
<%= link_to t("admin.sessions.show.open_replays"), admin_club_recordings_path(club) %>
<% end %>
</dd>
</div>
<% end %>
</dl>
</section>
</div>
<%= render "admin/sessions/links", session: @session %>
<% if @session.youtube_broadcast_id %>
<p><%= t("admin.sessions.show.youtube_studio") %>: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener"><%= t("admin.sessions.show.broadcast") %></a></p>
<% if @session.device_states.any? %>
<section class="panel">
<h3><%= t("admin.sessions.show.devices_title") %></h3>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.sessions.show.devices.role") %></th>
<th><%= t("admin.sessions.show.devices.network") %></th>
<th><%= t("admin.sessions.show.devices.battery") %></th>
<th><%= t("admin.sessions.show.devices.bitrate") %></th>
<th><%= t("admin.sessions.show.devices.fps") %></th>
<th><%= t("admin.sessions.show.devices.last_seen") %></th>
</tr>
</thead>
<tbody>
<% @session.device_states.order(:device_role).each do |device| %>
<tr>
<td><%= device.device_role %></td>
<td class="muted"><%= device.network_type.presence || t("admin.common.dash") %></td>
<td class="muted"><%= device.battery_level.nil? ? t("admin.common.dash") : "#{device.battery_level}%" %></td>
<td class="muted">
<% if device.current_bitrate || device.target_bitrate %>
<%= device.current_bitrate || t("admin.common.dash") %>
<span class="muted">/ <%= device.target_bitrate || t("admin.common.dash") %></span>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</td>
<td class="muted"><%= device.fps || t("admin.common.dash") %></td>
<td class="muted"><%= admin_datetime(device.last_seen_at, with_seconds: true) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</section>
<% end %>
<p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p>
<h3><%= t("admin.sessions.show.events_title") %></h3>
<table>
<thead>
<tr>
<th><%= t("admin.sessions.show.table.type") %></th>
<th><%= t("admin.sessions.show.table.when") %></th>
<th><%= t("admin.sessions.show.table.meta") %></th>
</tr>
</thead>
<tbody>
<% @events.each do |e| %>
<tr>
<td><%= e.event_type %></td>
<td><%= e.occurred_at %></td>
<td><%= e.metadata.to_json %></td>
</tr>
<% end %>
</tbody>
</table>
<section class="panel">
<h3><%= t("admin.sessions.show.events_title") %></h3>
<% if @events.any? %>
<div class="admin-table-wrap">
<table class="admin-table admin-table--events">
<thead>
<tr>
<th><%= t("admin.sessions.show.table.type") %></th>
<th><%= t("admin.sessions.show.table.when") %></th>
<th><%= t("admin.sessions.show.table.meta") %></th>
</tr>
</thead>
<tbody>
<% @events.each do |e| %>
<tr>
<td><span class="admin-event-type"><%= e.event_type %></span></td>
<td class="muted"><%= admin_datetime(e.occurred_at, with_seconds: true) %></td>
<td><%= admin_format_event_meta(e.metadata) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.sessions.show.events_none") %></p>
<% end %>
</section>
@@ -0,0 +1,13 @@
<p><strong><%= t("mailers.contact.heading") %></strong></p>
<p>
<%= t("mailers.contact.name") %> <%= @name %><br>
<%= t("mailers.contact.email") %> <%= @email %><br>
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %><br>
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
</p>
<p><%= t("mailers.contact.message") %></p>
<p style="white-space:pre-wrap"><%= @message %></p>
<p style="color:#666;font-size:12px"><%= t("mailers.contact.footer") %></p>
@@ -0,0 +1,11 @@
<%= t("mailers.contact.heading") %>
<%= t("mailers.contact.name") %> <%= @name %>
<%= t("mailers.contact.email") %> <%= @email %>
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %>
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
<%= t("mailers.contact.message") %>
<%= @message %>
<%= t("mailers.contact.footer") %>
+4 -1
View File
@@ -4,7 +4,8 @@
<title><%= t("admin.layout.title") %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=4">
<%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=15">
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42">
<% end %>
@@ -25,10 +26,12 @@
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
<%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
<% end %>
<%= link_to t("admin.layout.nav.announcements"), admin_announcements_path, class: ("active" if controller_name == "announcements") %>
<%= link_to t("admin.layout.nav.clubs"), admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
+5 -2
View File
@@ -5,14 +5,16 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<meta name="application-name" content="Match Live TV">
<%= csrf_meta_tags %>
<%= render "shared/meta_tags" %>
<%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=64">
<link rel="stylesheet" href="/marketing.css?v=68">
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
<%= render "shared/site_announcements" %>
<main>
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
@@ -24,6 +26,7 @@
<script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script>
<script src="/cookie-consent.js?v=1" defer></script>
<script src="/site-analytics.js?v=4" defer></script>
<script src="/cookie-consent.js?v=2" defer></script>
</body>
</html>
@@ -4,21 +4,24 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= csrf_meta_tags %>
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=64">
<link rel="stylesheet" href="/marketing.css?v=68">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
<%= render "shared/marketing_nav" %>
<%= render "shared/site_announcements" %>
<main class="live-main">
<%= yield %>
</main>
<%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script>
<script src="/cookie-consent.js?v=1" defer></script>
<script src="/site-analytics.js?v=4" defer></script>
<script src="/cookie-consent.js?v=2" defer></script>
</body>
</html>
+1 -1
View File
@@ -1,7 +1,7 @@
<% content_for :title, t("club.edit.title", name: @club.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap" style="padding-top:20px;max-width:560px">
<div class="wrap wrap--portal" style="padding-top:20px">
<h1><%= t("club.edit.heading") %></h1>
<div class="card">
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
@@ -0,0 +1,124 @@
<% content_for :title, t("pages.contacts.meta_title") %>
<% content_for :meta_description, t("pages.contacts.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_contatti_path) %>
<article class="contacts-page">
<header class="features-hero contacts-hero">
<div class="wrap">
<p class="features-hero__eyebrow"><%= t("pages.contacts.eyebrow") %></p>
<h1><%= t("pages.contacts.title") %></h1>
<p class="features-hero__lead"><%= t("pages.contacts.lead") %></p>
<p class="contacts-hero__meta"><%= t("pages.contacts.response_time") %></p>
</div>
</header>
<section class="section wrap" aria-label="<%= t("pages.contacts.title") %>">
<div class="contacts-channels">
<article class="feature-card">
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-briefcase"></i></span>
<h2><%= t("pages.contacts.commercial_title") %></h2>
<p><%= t("pages.contacts.commercial_body") %></p>
<p class="contacts-channels__email">
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
</p>
</article>
<article class="feature-card">
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-life-ring"></i></span>
<h2><%= t("pages.contacts.support_title") %></h2>
<p>
<%= raw t(
"pages.contacts.support_body_html",
faq_link: link_to(t("pages.contacts.faq_link"), public_faq_path),
support_link: link_to(t("pages.contacts.support_link"), public_support_path)
) %>
</p>
<p class="contacts-channels__email">
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
</p>
</article>
<article class="feature-card">
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-shield-halved"></i></span>
<h2><%= t("pages.contacts.privacy_title") %></h2>
<p><%= t("pages.contacts.privacy_body") %></p>
<p class="contacts-channels__email">
<%= mail_to MatchLiveTv.privacy_controller_email, MatchLiveTv.privacy_controller_email %>
</p>
</article>
</div>
</section>
<section class="section wrap" aria-labelledby="contacts-company-title">
<div class="features-panel contacts-company">
<h2 id="contacts-company-title"><%= t("pages.contacts.company_title") %></h2>
<p class="features-panel__lead"><%= t("pages.contacts.company_lead") %></p>
<ul class="contacts-company__details">
<li><strong><%= MatchLiveTv.privacy_controller_name %></strong></li>
<li><%= t("pages.contacts.label_address") %>: <%= MatchLiveTv.privacy_controller_address %></li>
<% if MatchLiveTv.privacy_controller_vat.present? %>
<li><%= t("pages.contacts.label_vat") %>: <%= MatchLiveTv.privacy_controller_vat %></li>
<% end %>
</ul>
</div>
</section>
<section class="section wrap" aria-labelledby="contacts-form-title">
<div class="contacts-form-panel">
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
<p class="contacts-form-panel__lead"><%= t("pages.contacts.form_lead") %></p>
<%= form_with url: public_contatti_path, method: :post, local: true, class: "contacts-form" do %>
<div class="contacts-hp" aria-hidden="true">
<label for="website"><%= t("pages.contacts.honeypot_label") %></label>
<%= text_field_tag :website, @inquiry[:website], tabindex: -1, autocomplete: "off" %>
</div>
<%= label_tag :name, t("pages.contacts.name_label") %>
<%= text_field_tag :name, @inquiry[:name], required: true, maxlength: 120, autocomplete: "name" %>
<%= label_tag :email, t("pages.contacts.email_label") %>
<%= email_field_tag :email, @inquiry[:email], required: true, autocomplete: "email" %>
<%= label_tag :club_name, t("pages.contacts.club_label") %>
<%= text_field_tag :club_name, @inquiry[:club_name], maxlength: 160, autocomplete: "organization" %>
<%= label_tag :topic, t("pages.contacts.topic_label") %>
<%= select_tag :topic, options_for_select(
[
[t("pages.contacts.topic_commercial"), "commercial"],
[t("pages.contacts.topic_support"), "support"],
[t("pages.contacts.topic_privacy"), "privacy"]
],
@inquiry[:topic]
), required: true %>
<%= label_tag :message, t("pages.contacts.message_label") %>
<%= text_area_tag :message, @inquiry[:message], required: true, rows: 6, maxlength: 4000 %>
<label class="legal-accept">
<%= check_box_tag :accept_privacy, "1", @inquiry[:accept_privacy] == "1", required: true %>
<span class="legal-accept-text">
<%= raw t(
"pages.contacts.privacy_accept_html",
privacy_link: link_to(t("pages.contacts.privacy_link"), public_privacy_path, target: "_blank", rel: "noopener")
) %>
</span>
</label>
<%= submit_tag t("pages.contacts.submit"), class: "btn btn-primary" %>
<% end %>
</div>
</section>
<section class="section wrap features-cta" aria-labelledby="contacts-cta-title">
<div class="features-cta__box">
<h2 id="contacts-cta-title"><%= t("pages.contacts.cta_title") %></h2>
<p><%= t("pages.contacts.cta_body") %></p>
<div class="features-cta__actions">
<%= link_to t("pages.contacts.cta_faq"), public_faq_path, class: "btn btn-outline" %>
<%= link_to t("pages.contacts.cta_signup"), public_signup_path, class: "btn btn-primary" %>
</div>
</div>
</section>
</article>
@@ -101,6 +101,12 @@
<td><%= t("legal.cookies.s4_2_row3_duration") %></td>
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
</tr>
<tr>
<td><%= raw t("legal.cookies.s4_2_row4_name_html") %></td>
<td><%= t("legal.cookies.s4_2_row4_purpose") %></td>
<td><%= t("legal.cookies.s4_2_row4_duration") %></td>
<td><%= t("legal.cookies.s4_2_row4_provider") %></td>
</tr>
</tbody>
</table>
</div>
@@ -83,6 +83,6 @@
<div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
<%= link_to t("pages.pricing.contact_button"), public_contatti_path, class: "btn btn-primary" %>
</div>
</div>
@@ -43,6 +43,7 @@
<li><%= raw t("legal.privacy.s3_item4_html") %></li>
<li><%= raw t("legal.privacy.s3_item5_html") %></li>
<li><%= raw t("legal.privacy.s3_item6_html") %></li>
<li><%= raw t("legal.privacy.s3_item7_html") %></li>
</ul>
</section>
@@ -107,6 +108,10 @@
<td><%= t("legal.privacy.s5_row6_purpose") %></td>
<td><%= t("legal.privacy.s5_row6_basis") %></td>
</tr>
<tr>
<td><%= t("legal.privacy.s5_row7_purpose") %></td>
<td><%= t("legal.privacy.s5_row7_basis") %></td>
</tr>
</tbody>
</table>
</div>
@@ -144,6 +149,7 @@
<li><%= raw t("legal.privacy.s8_item2_html") %></li>
<li><%= raw t("legal.privacy.s8_item3_html") %></li>
<li><%= raw t("legal.privacy.s8_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
<li><%= raw t("legal.privacy.s8_item5_html") %></li>
</ul>
</section>
+1 -1
View File
@@ -1,7 +1,7 @@
<% content_for :title, t("team.edit.title", name: @team.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap" style="padding-top:20px;max-width:560px">
<div class="wrap wrap--portal" style="padding-top:20px">
<h1><%= @team.name %></h1>
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
<div class="card">
+46 -19
View File
@@ -1,7 +1,9 @@
<% record = local_assigns[:record] %>
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
<% can_upload = local_assigns.fetch(:can_upload, false) %>
<% cover_src = record.effective_cover_url.presence || Streams::CoverResolver::DEFAULT_COVER_URL %>
<% default_cover = Streams::CoverResolver::DEFAULT_COVER_URL %>
<% cover_src = record.effective_cover_url.presence || default_cover %>
<% remove_id = "remove_cover_#{record.model_name.param_key}" %>
<fieldset class="branding-fields cover-fields">
<legend><%= t("club.branding.cover_legend") %></legend>
@@ -22,16 +24,25 @@
</div>
<% if can_upload %>
<%= file_field_tag "branding[cover_image]",
accept: "image/png,image/jpeg,image/webp",
data: { branding_cover_file: true },
class: "branding-file-input" %>
<% if record.cover_image_attached? %>
<label class="checkbox-label cover-remove-label">
<%= check_box_tag "remove_cover", "1", false, id: "remove_cover_#{record.model_name.param_key}" %>
<%= t("club.branding.cover_remove_label") %>
</label>
<% end %>
<div class="cover-actions">
<%= file_field_tag "branding[cover_image]",
accept: "image/png,image/jpeg,image/webp",
data: { branding_cover_file: true },
class: "branding-file-input" %>
<% if record.cover_image_attached? %>
<%= hidden_field_tag "remove_cover", "0", id: remove_id, data: { branding_cover_remove: true } %>
<button type="button"
class="btn btn-secondary cover-reset-btn"
data-branding-cover-reset
data-default-cover="<%= default_cover %>"
data-remove-input="#<%= remove_id %>">
<%= t("club.branding.cover_reset_button") %>
</button>
<p class="branding-hint cover-reset-hint" data-branding-cover-reset-hint hidden>
<%= t("club.branding.cover_reset_pending_hint") %>
</p>
<% end %>
</div>
<% else %>
<p class="muted"><%= t("club.branding.cover_requires_full") %></p>
<%= link_to t("club.branding.cover_see_plans"), public_prezzi_path, class: "btn btn-secondary btn-sm" %>
@@ -43,13 +54,29 @@
(function () {
var input = document.querySelector("[data-branding-cover-file]");
var img = document.querySelector("[data-branding-cover-img]");
if (!input || !img) return;
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function (e) { img.src = e.target.result; };
reader.readAsDataURL(file);
});
var resetBtn = document.querySelector("[data-branding-cover-reset]");
var removeInput = document.querySelector("[data-branding-cover-remove]");
var resetHint = document.querySelector("[data-branding-cover-reset-hint]");
if (input && img) {
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) return;
if (removeInput) removeInput.value = "0";
if (resetHint) resetHint.hidden = true;
if (resetBtn) resetBtn.disabled = false;
var reader = new FileReader();
reader.onload = function (e) { img.src = e.target.result; };
reader.readAsDataURL(file);
});
}
if (resetBtn && img && removeInput) {
resetBtn.addEventListener("click", function () {
removeInput.value = "1";
img.src = resetBtn.getAttribute("data-default-cover");
if (input) input.value = "";
if (resetHint) resetHint.hidden = false;
resetBtn.disabled = true;
});
}
})();
</script>
@@ -5,6 +5,7 @@
<%= render "shared/store_badges", variant: "footer" %>
</div>
<div>
<%= link_to t("common.contacts"), public_contatti_path %> ·
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> ·
@@ -0,0 +1,46 @@
<% announcements = @site_announcements %>
<% if announcements.present? %>
<div class="site-announcements" role="region" aria-label="<%= t("announcement.region") %>">
<% announcements.each do |item| %>
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
data-announcement-id="<%= item.id %>">
<div class="site-announcement__body">
<p class="site-announcement__title"><%= item.localized_title %></p>
<div class="site-announcement__text"><%= simple_format(item.localized_body) %></div>
<% action_url = item.resolved_action_url(nil) %>
<% if action_url.present? %>
<p class="site-announcement__action">
<%= link_to (item.localized_action_label.presence || t("announcement.open")), action_url,
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
</p>
<% end %>
</div>
<% if item.dismissible? %>
<button type="button" class="site-announcement__dismiss" data-announcement-dismiss aria-label="<%= t("announcement.dismiss") %>">
×
</button>
<% end %>
</article>
<% end %>
</div>
<script>
(function () {
var key = "mltv.webDismissedAnnouncements";
var dismissed = [];
try { dismissed = JSON.parse(localStorage.getItem(key) || "[]"); } catch (e) {}
document.querySelectorAll(".site-announcement[data-announcement-id]").forEach(function (el) {
if (dismissed.indexOf(el.getAttribute("data-announcement-id")) !== -1) el.remove();
});
document.querySelectorAll("[data-announcement-dismiss]").forEach(function (btn) {
btn.addEventListener("click", function () {
var box = btn.closest("[data-announcement-id]");
if (!box) return;
var id = box.getAttribute("data-announcement-id");
if (dismissed.indexOf(id) === -1) dismissed.push(id);
localStorage.setItem(key, JSON.stringify(dismissed));
box.remove();
});
});
})();
</script>
<% end %>
+182 -2
View File
@@ -5,10 +5,12 @@ de:
nav:
dashboard: Dashboard
ops: Ops
announcements: Hinweise
clubs: Vereine & Teams
billing: Abrechnung
youtube: YouTube
sessions: Sitzungen
analytics: Analytics
stream_nodes: Stream-Knoten
password: Passwort
logout: Abmelden
@@ -45,6 +47,9 @@ de:
invoice_updated: "Rechnung %{number} aktualisiert."
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert
announcement_destroyed: Hinweis gelöscht
common:
free_plan: Free
yes: "Ja"
@@ -106,6 +111,7 @@ de:
table:
match: Spiel
status: Status
ingest: Ingest
start: Start
link: Link
none: Derzeit keine aktive Sitzung.
@@ -246,27 +252,88 @@ de:
sessions:
index:
title: Stream-Sitzungen
lead: Suche nach Verein, Team, Gegner oder Ort. Filtere nach Status, Plattform, Knoten und Zeitraum.
results: "%{shown} von %{total} angezeigt"
none: Keine Sitzungen für diese Filter.
filters:
q: Suche
q_placeholder: Verein, Team, Gegner…
status: Status
platform: Plattform
club: Verein
node: Ingest-Knoten
from: Von
to: Bis
any: Alle
apply: Filtern
reset: Zurücksetzen
table:
club: Verein
match: Spiel
status: Status
started: Start
ended: Ende
duration: Dauer
ingest: Ingest
disconnects: Verbindungsabbrüche
link: Link
detail: Details
regia: Regie
show:
title: "Sitzung %{id}"
heading: Sitzungsdetails
back: "← Zurück zu Sitzungen"
club_label: "Verein:"
summary_title: Übersicht
timing_title: Zeiten
ingest_title: Ingest & Medien
devices_title: Geräte
events_title: Ereignisse
events_none: Keine Ereignisse erfasst.
status_label: "Status:"
stop_button: Sitzung beenden
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
match_label: "Spiel: %{team} gegen %{opponent}"
youtube_studio: YouTube Studio
broadcast: Übertragung
open_replays: Vereins-Replays öffnen
audio_muted: Audio stumm
audio_on: Audio an
ingest_node: "Ingest-Knoten:"
rtmp_ingest: "RTMP-Ingest:"
events_title: Ereignisse
fields:
id: ID
club: Verein
team: Team
opponent: Gegner
operator: Operator
platform: Plattform
privacy: Privacy
quality: Qualität
min_quality: Min. Qualität
audio: Audio
created: Erstellt
started: Start
ended: Ende
duration: Dauer
disconnects: Abbrüche
scheduled: Geplantes Spiel
location: Ort
node: Knoten
rtmp: RTMP-Ingest
hls: HLS
recording: Replay
devices:
role: Rolle
network: Netz
battery: Akku
bitrate: Bitrate
fps: FPS
last_seen: Zuletzt gesehen
table:
type: Typ
when: Wann
meta: Meta
meta: Details
links:
watch_title: Live-Link
none: Kein Video-Link für diese Sitzung verfügbar.
@@ -277,6 +344,54 @@ de:
active_exists: Es existiert bereits ein aktiver Regie-Link (die URL kann nicht abgerufen werden). Erzeuge einen neuen, wenn du ihn erneut teilen musst.
none_active: Kein aktiver Regie-Link. Erzeuge einen Link zum Teilen mit der Person, die den Spielstand verwaltet.
generate_button: Regie-Link erzeugen
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
analytics:
missing_path: Seite auswählen.
filters:
from: Von
to: Bis
device: Gerät
any: Alle
apply: Filtern
reset: Zurücksetzen
layer: Ebene
layer_move: Mausbewegungen
layer_click: Klicks
index:
title: Website-Analytics
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
none: Keine Daten im gewählten Zeitraum.
heatmap: Heatmap
table:
path: Seite
pageviews: Pageviews
moves: Bewegungen
clicks: Klicks
avg_scroll: Scroll Ø
max_scroll: Scroll max
show:
title: Seiten-Heatmap
back: "← Zurück zu Analytics"
summary: Übersicht
scroll_depth: Scrolltiefe
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
heatmap_moves: Mausbewegungs-Karte
heatmap_clicks: Klickkarte
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
preview_title: Seiten-Screenshot
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen.
snapshot_meta: "Screenshot %{device} · %{at}"
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
heatmap_title: Klickkarte
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
billing:
index:
title: Zu berechnende Zahlungen
@@ -360,3 +475,68 @@ de:
club_recordings:
index:
title: "Replays — %{club}"
announcements:
index:
title: Hinweise
lead: Meldungen für die Apps, die öffentliche Website und den Login-Bereich — Wartung, Updates oder Mitteilungen.
new: Neuer Hinweis
table_title: Hinweise
none: Noch keine Hinweise. Erstelle einen, um Apps und Website zu informieren.
new:
title: Neuer Hinweis
edit:
title: Hinweis bearbeiten
table:
status: Status
kind: Typ
title: Nachricht
channels: Ziele
window: Zeitraum
actions:
edit: Bearbeiten
delete: Löschen
delete_confirm: Diesen Hinweis löschen? Er erscheint nicht mehr an den gewählten Stellen.
kind:
info: Information
maintenance: Wartung
update: App-Update
severity:
info: Info
warning: Warnung
critical: Kritisch
status:
draft: Entwurf
published: Veröffentlicht
archived: Archiviert
channels:
android: Android-App
ios: iOS-App
web_public: Öffentliche Website
web_private: Login-Bereich
window:
always: Immer (solange veröffentlicht)
from: "Ab %{time}"
until: "Bis %{time}"
form:
kind: Typ
severity: Schwere
status: Status
channels: Wo anzeigen
channels_hint: "Eine oder mehrere Ziele wählen. Android und iOS sind unabhängig: bei unterschiedlichem Text zwei Hinweise anlegen. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto)."
title: Titel
body: Text
starts_at: Sichtbar ab
ends_at: Sichtbar bis
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
action_url: Link (optional)
action_url_hint: Bei Updates öffnet eine leere URL den Store der Plattform (Play Store auf Android, App Store auf iOS).
action_label: Button-Text
locales_legend: Text pro Sprache
locales_hint: Alle App-Sprachen ausfüllen (Italienisch, Englisch, Französisch, Deutsch, Spanisch). Italienisch ist Pflicht; fehlende Sprachen fallen auf Englisch, dann Italienisch zurück.
locale_required: Pflicht
dismissible: Nutzer können den Hinweis schließen
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
submit_create: Hinweis speichern
submit_update: Hinweis aktualisieren
cancel: Abbrechen
+182 -2
View File
@@ -5,10 +5,12 @@ en:
nav:
dashboard: Dashboard
ops: Ops
announcements: Notices
clubs: Clubs & teams
billing: Billing
youtube: YouTube
sessions: Sessions
analytics: Analytics
stream_nodes: Stream nodes
password: Password
logout: Log out
@@ -45,6 +47,9 @@ en:
invoice_updated: "Invoice %{number} updated."
invoice_uploaded: "Invoice uploaded and sent to %{email}."
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
announcement_created: Notice saved
announcement_updated: Notice updated
announcement_destroyed: Notice deleted
common:
free_plan: Free
yes: "Yes"
@@ -106,6 +111,7 @@ en:
table:
match: Match
status: Status
ingest: Ingest
start: Start
link: Link
none: No active session right now.
@@ -246,27 +252,88 @@ en:
sessions:
index:
title: Stream sessions
lead: Search by club, team, opponent or venue. Filter by status, platform, node and date range.
results: "Showing %{shown} of %{total}"
none: No sessions match these filters.
filters:
q: Search
q_placeholder: Club, team, opponent…
status: Status
platform: Platform
club: Club
node: Ingest node
from: From
to: To
any: All
apply: Filter
reset: Reset
table:
club: Club
match: Match
status: Status
started: Started
ended: Ended
duration: Duration
ingest: Ingest
disconnects: Disconnects
link: Link
detail: Details
regia: Control
show:
title: "Session %{id}"
heading: Session details
back: "← Back to sessions"
club_label: "Club:"
summary_title: Summary
timing_title: Timing
ingest_title: Ingest & media
devices_title: Devices
events_title: Events
events_none: No events recorded.
status_label: "Status:"
stop_button: End session
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
match_label: "Match: %{team} vs %{opponent}"
youtube_studio: YouTube Studio
broadcast: Broadcast
open_replays: Open club replays
audio_muted: Audio muted
audio_on: Audio on
ingest_node: "Ingest node:"
rtmp_ingest: "RTMP ingest:"
events_title: Events
fields:
id: ID
club: Club
team: Team
opponent: Opponent
operator: Operator
platform: Platform
privacy: Privacy
quality: Quality
min_quality: Min quality
audio: Audio
created: Created
started: Started
ended: Ended
duration: Duration
disconnects: Disconnects
scheduled: Scheduled match
location: Venue
node: Node
rtmp: RTMP ingest
hls: HLS
recording: Replay
devices:
role: Role
network: Network
battery: Battery
bitrate: Bitrate
fps: FPS
last_seen: Last seen
table:
type: Type
when: When
meta: Meta
meta: Details
links:
watch_title: Live link
none: No video link available for this session.
@@ -277,6 +344,54 @@ en:
active_exists: An active control link already exists (the URL cannot be retrieved). Generate a new one if you need to share it again.
none_active: No active control link. Generate a link to share with whoever manages the score.
generate_button: Generate control link
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
analytics:
missing_path: Select a page.
filters:
from: From
to: To
device: Device
any: All
apply: Filter
reset: Reset
layer: Layer
layer_move: Mouse moves
layer_click: Clicks
index:
title: Site analytics
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
none: No data in the selected period.
heatmap: Heatmap
table:
path: Page
pageviews: Pageviews
moves: Moves
clicks: Clicks
avg_scroll: Avg scroll
max_scroll: Max scroll
show:
title: Page heatmap
back: "← Back to Analytics"
summary: Summary
scroll_depth: Scroll depth
scroll_hint: "Average %{avg}% · max %{max}%"
heatmap_moves: Mouse-move map
heatmap_clicks: Click map
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
preview_title: Page screenshot
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds.
snapshot_meta: "Screenshot %{device} · captured %{at}"
no_points: No aggregated points for this layer in the period.
heatmap_title: Click map
no_clicks: No aggregated clicks for this page in the period.
billing:
index:
title: Payments to invoice
@@ -360,3 +475,68 @@ en:
club_recordings:
index:
title: "Replays — %{club}"
announcements:
index:
title: Notices
lead: Messages for the apps, the public website and the logged-in area — maintenance, updates or announcements.
new: New notice
table_title: Notices
none: No notices yet. Create one to notify the apps and the website.
new:
title: New notice
edit:
title: Edit notice
table:
status: Status
kind: Type
title: Message
channels: Destinations
window: Window
actions:
edit: Edit
delete: Delete
delete_confirm: Delete this notice? It will no longer appear in the selected destinations.
kind:
info: Information
maintenance: Maintenance
update: App update
severity:
info: Info
warning: Warning
critical: Critical
status:
draft: Draft
published: Published
archived: Archived
channels:
android: Android app
ios: iOS app
web_public: Public website
web_private: Logged-in area
window:
always: Always (while published)
from: "From %{time}"
until: "Until %{time}"
form:
kind: Type
severity: Severity
status: Status
channels: Where to show it
channels_hint: "Select one or more destinations. Android and iOS are independent: create two notices if the copy should differ. The logged-in area is visible after sign-in (club, teams, account)."
title: Title
body: Body
starts_at: Visible from
ends_at: Visible until
window_hint: Leave empty to show immediately while published.
action_url: Link (optional)
action_url_hint: For updates, an empty URL opens that platform's store (Play Store on Android, App Store on iOS).
action_label: Button label
locales_legend: Copy by language
locales_hint: Fill in every app language (Italian, English, French, German, Spanish). Italian is required; missing languages fall back to English, then Italian.
locale_required: required
dismissible: Users can dismiss the notice
dismissible_hint: Uncheck for maintenance notices that should stay visible.
submit_create: Save notice
submit_update: Update notice
cancel: Cancel
+182 -2
View File
@@ -5,10 +5,12 @@ es:
nav:
dashboard: Panel
ops: Ops
announcements: Avisos
clubs: Clubes y equipos
billing: Facturación
youtube: YouTube
sessions: Sesiones
analytics: Analytics
stream_nodes: Nodos stream
password: Contraseña
logout: Salir
@@ -45,6 +47,9 @@ es:
invoice_updated: "Factura %{number} actualizada."
invoice_uploaded: "Factura subida y enviada a %{email}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
announcement_created: Aviso guardado
announcement_updated: Aviso actualizado
announcement_destroyed: Aviso eliminado
common:
free_plan: Free
yes: "Sí"
@@ -106,6 +111,7 @@ es:
table:
match: Partido
status: Estado
ingest: Ingest
start: Inicio
link: Enlace
none: No hay ninguna sesión activa en este momento.
@@ -246,27 +252,88 @@ es:
sessions:
index:
title: Sesiones de emisión
lead: Busca por club, equipo, rival o sede. Filtra por estado, plataforma, nodo y periodo.
results: "Mostrando %{shown} de %{total}"
none: Ninguna sesión coincide con estos filtros.
filters:
q: Buscar
q_placeholder: Club, equipo, rival…
status: Estado
platform: Plataforma
club: Club
node: Nodo ingest
from: Desde
to: Hasta
any: Todos
apply: Filtrar
reset: Restablecer
table:
club: Club
match: Partido
status: Estado
started: Inicio
ended: Fin
duration: Duración
ingest: Ingest
disconnects: Desconexiones
link: Enlace
detail: Detalle
regia: Regie
show:
title: "Sesión %{id}"
heading: Detalle de sesión
back: "← Volver a sesiones"
club_label: "Club:"
summary_title: Resumen
timing_title: Tiempos
ingest_title: Ingest y medios
devices_title: Dispositivos
events_title: Eventos
events_none: No hay eventos registrados.
status_label: "Estado:"
stop_button: Finalizar sesión
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
match_label: "Partido: %{team} vs %{opponent}"
youtube_studio: YouTube Studio
broadcast: Emisión
open_replays: Abrir replays del club
audio_muted: Audio silenciado
audio_on: Audio activo
ingest_node: "Nodo ingest:"
rtmp_ingest: "Ingesta RTMP:"
events_title: Eventos
fields:
id: ID
club: Club
team: Equipo
opponent: Rival
operator: Operador
platform: Plataforma
privacy: Privacidad
quality: Calidad
min_quality: Calidad mínima
audio: Audio
created: Creada
started: Inicio
ended: Fin
duration: Duración
disconnects: Desconexiones
scheduled: Partido programado
location: Sede
node: Nodo
rtmp: Ingesta RTMP
hls: HLS
recording: Replay
devices:
role: Rol
network: Red
battery: Batería
bitrate: Bitrate
fps: FPS
last_seen: Último contacto
table:
type: Tipo
when: Cuándo
meta: Meta
meta: Detalles
links:
watch_title: Enlace en directo
none: No hay ningún enlace de vídeo disponible para esta sesión.
@@ -277,6 +344,54 @@ es:
active_exists: Ya existe un enlace de regie activo (la URL no se puede recuperar). Genera uno nuevo si necesitas compartirlo de nuevo.
none_active: No hay ningún enlace de regie activo. Genera un enlace para compartir con quien gestione el marcador.
generate_button: Generar enlace de regie
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
analytics:
missing_path: Selecciona una página.
filters:
from: Desde
to: Hasta
device: Dispositivo
any: Todos
apply: Filtrar
reset: Restablecer
layer: Capa
layer_move: Movimientos del ratón
layer_click: Clics
index:
title: Analytics del sitio
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.
heatmap: Heatmap
table:
path: Página
pageviews: Pageviews
moves: Movimientos
clicks: Clics
avg_scroll: Scroll medio
max_scroll: Scroll máx
show:
title: Heatmap de página
back: "← Volver a Analytics"
summary: Resumen
scroll_depth: Profundidad de scroll
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 captura de la página. Verde = poco, rojo = mucho.
preview_title: Captura de página
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo.
snapshot_meta: "Captura %{device} · %{at}"
no_points: No hay puntos agregados para esta capa en el periodo.
heatmap_title: Mapa de clics
no_clicks: No hay clics agregados para esta página en el periodo.
billing:
index:
title: Pagos por facturar
@@ -360,3 +475,68 @@ es:
club_recordings:
index:
title: "Repeticiones — %{club}"
announcements:
index:
title: Avisos
lead: Mensajes hacia las apps, el sitio público y el área privada — mantenimiento, actualizaciones o comunicados.
new: Nuevo aviso
table_title: Avisos
none: Todavía no hay avisos. Crea uno para notificar a las apps y al sitio.
new:
title: Nuevo aviso
edit:
title: Editar aviso
table:
status: Estado
kind: Tipo
title: Mensaje
channels: Destinos
window: Ventana
actions:
edit: Editar
delete: Eliminar
delete_confirm: ¿Eliminar este aviso? Ya no se verá en los destinos elegidos.
kind:
info: Información
maintenance: Mantenimiento
update: Actualización
severity:
info: Info
warning: Atención
critical: Crítico
status:
draft: Borrador
published: Publicado
archived: Archivado
channels:
android: App Android
ios: App iOS
web_public: Sitio público
web_private: Área privada
window:
always: Siempre (mientras esté publicado)
from: "Desde %{time}"
until: "Hasta %{time}"
form:
kind: Tipo
severity: Gravedad
status: Estado
channels: Dónde mostrarlo
channels_hint: "Elige uno o más destinos. Android e iOS son independientes: si el texto debe ser distinto, crea dos avisos. El área privada se ve tras iniciar sesión (club, equipos, cuenta)."
title: Título
body: Texto
starts_at: Visible desde
ends_at: Visible hasta
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
action_url: Enlace (opcional)
action_url_hint: En actualizaciones, una URL vacía abre la tienda de esa plataforma (Play Store en Android, App Store en iOS).
action_label: Texto del botón
locales_legend: Texto por idioma
locales_hint: Completa todos los idiomas de la app (italiano, inglés, francés, alemán, español). El italiano es obligatorio; si falta un idioma, se usa inglés y luego italiano.
locale_required: obligatorio
dismissible: El usuario puede cerrar el aviso
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
submit_create: Guardar aviso
submit_update: Actualizar aviso
cancel: Cancelar
+182 -2
View File
@@ -5,10 +5,12 @@ fr:
nav:
dashboard: Tableau de bord
ops: Ops
announcements: Alertes
clubs: Clubs et équipes
billing: Facturation
youtube: YouTube
sessions: Sessions
analytics: Analytics
stream_nodes: Nœuds stream
password: Mot de passe
logout: Déconnexion
@@ -45,6 +47,9 @@ fr:
invoice_updated: "Facture %{number} mise à jour."
invoice_uploaded: "Facture chargée et envoyée à %{email}."
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour
announcement_destroyed: Alerte supprimée
common:
free_plan: Free
yes: "Oui"
@@ -106,6 +111,7 @@ fr:
table:
match: Match
status: Statut
ingest: Ingest
start: Début
link: Lien
none: Aucune session active pour le moment.
@@ -246,27 +252,88 @@ fr:
sessions:
index:
title: Sessions de diffusion
lead: Recherchez par club, équipe, adversaire ou lieu. Filtrez par statut, plateforme, nœud et période.
results: "%{shown} sur %{total} affichées"
none: Aucune session ne correspond à ces filtres.
filters:
q: Recherche
q_placeholder: Club, équipe, adversaire…
status: Statut
platform: Plateforme
club: Club
node: Nœud ingest
from: Du
to: Au
any: Tous
apply: Filtrer
reset: Réinitialiser
table:
club: Club
match: Match
status: Statut
started: Début
ended: Fin
duration: Durée
ingest: Ingest
disconnects: Déconnexions
link: Lien
detail: Détail
regia: Régie
show:
title: "Session %{id}"
heading: Détail de la session
back: "← Retour aux sessions"
club_label: "Club :"
summary_title: Résumé
timing_title: Horaires
ingest_title: Ingest et médias
devices_title: Appareils
events_title: Événements
events_none: Aucun événement enregistré.
status_label: "Statut :"
stop_button: Terminer la session
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
match_label: "Match : %{team} contre %{opponent}"
youtube_studio: YouTube Studio
broadcast: Diffusion
open_replays: Ouvrir les replays du club
audio_muted: Audio coupé
audio_on: Audio actif
ingest_node: "Nœud ingest :"
rtmp_ingest: "Ingestion RTMP :"
events_title: Événements
fields:
id: ID
club: Club
team: Équipe
opponent: Adversaire
operator: Opérateur
platform: Plateforme
privacy: Confidentialité
quality: Qualité
min_quality: Qualité mini
audio: Audio
created: Créée
started: Début
ended: Fin
duration: Durée
disconnects: Déconnexions
scheduled: Match programmé
location: Lieu
node: Nœud
rtmp: Ingestion RTMP
hls: HLS
recording: Replay
devices:
role: Rôle
network: Réseau
battery: Batterie
bitrate: Débit
fps: FPS
last_seen: Dernier contact
table:
type: Type
when: Quand
meta: Méta
meta: Détails
links:
watch_title: Lien en direct
none: Aucun lien vidéo disponible pour cette session.
@@ -277,6 +344,54 @@ fr:
active_exists: Un lien de régie actif existe déjà (l'URL n'est pas récupérable). Générez-en un nouveau si vous devez le repartager.
none_active: Aucun lien de régie actif. Générez un lien à partager avec la personne qui gère le score.
generate_button: Générer le lien de régie
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
analytics:
missing_path: Sélectionnez une page.
filters:
from: Du
to: Au
device: Appareil
any: Tous
apply: Filtrer
reset: Réinitialiser
layer: Couche
layer_move: Mouvements souris
layer_click: Clics
index:
title: Analytics du site
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.
heatmap: Heatmap
table:
path: Page
pageviews: Pages vues
moves: Mouvements
clicks: Clics
avg_scroll: Scroll moyen
max_scroll: Scroll max
show:
title: Heatmap de page
back: "← Retour aux Analytics"
summary: Résumé
scroll_depth: Profondeur de scroll
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
heatmap_moves: Carte des mouvements
heatmap_clicks: Carte des clics
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
preview_title: Capture de page
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez.
snapshot_meta: "Capture %{device} · %{at}"
no_points: Aucun point agrégé pour cette couche sur la période.
heatmap_title: Carte des clics
no_clicks: Aucun clic agrégé pour cette page sur la période.
billing:
index:
title: Paiements à facturer
@@ -360,3 +475,68 @@ fr:
club_recordings:
index:
title: "Replays — %{club}"
announcements:
index:
title: Alertes
lead: Messages vers les apps, le site public et l'espace connecté — maintenance, mises à jour ou communications.
new: Nouvelle alerte
table_title: Alertes
none: Aucune alerte. Créez-en une pour notifier les applications et le site.
new:
title: Nouvelle alerte
edit:
title: Modifier l'alerte
table:
status: Statut
kind: Type
title: Message
channels: Destinations
window: Fenêtre
actions:
edit: Modifier
delete: Supprimer
delete_confirm: Supprimer cette alerte ? Elle n'apparaîtra plus aux destinations choisies.
kind:
info: Information
maintenance: Maintenance
update: Mise à jour
severity:
info: Info
warning: Attention
critical: Critique
status:
draft: Brouillon
published: Publié
archived: Archivé
channels:
android: App Android
ios: App iOS
web_public: Site public
web_private: Espace connecté
window:
always: Toujours (tant que publié)
from: "À partir de %{time}"
until: "Jusqu'à %{time}"
form:
kind: Type
severity: Gravité
status: Statut
channels: Où l'afficher
channels_hint: "Choisissez une ou plusieurs destinations. Android et iOS sont indépendants : créez deux alertes si le texte doit différer. L'espace connecté est visible après connexion (club, équipes, compte)."
title: Titre
body: Texte
starts_at: Visible à partir de
ends_at: Visible jusqu'à
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
action_url: Lien (facultatif)
action_url_hint: Pour une mise à jour, une URL vide ouvre le store de la plateforme (Play Store sur Android, App Store sur iOS).
action_label: Libellé du bouton
locales_legend: Texte par langue
locales_hint: Renseignez toutes les langues de l'app (italien, anglais, français, allemand, espagnol). L'italien est obligatoire ; les langues manquantes basculent vers l'anglais puis l'italien.
locale_required: obligatoire
dismissible: L'utilisateur peut fermer l'alerte
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
submit_create: Enregistrer l'alerte
submit_update: Mettre à jour l'alerte
cancel: Annuler
+182 -2
View File
@@ -5,10 +5,12 @@ it:
nav:
dashboard: Dashboard
ops: Ops
announcements: Avvisi
clubs: Società e squadre
billing: Fatturazione
youtube: YouTube
sessions: Sessioni
analytics: Analytics
stream_nodes: Nodi stream
password: Password
logout: Esci
@@ -49,6 +51,9 @@ it:
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato.
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato
announcement_destroyed: Avviso eliminato
common:
free_plan: Free
yes: "Sì"
@@ -110,6 +115,7 @@ it:
table:
match: Partita
status: Stato
ingest: Ingest
start: Inizio
link: Link
none: Nessuna sessione attiva in questo momento.
@@ -267,27 +273,88 @@ it:
sessions:
index:
title: Sessioni di streaming
lead: Cerca per società, squadra, avversario o sede. Filtra per stato, piattaforma, nodo e periodo.
results: "Mostrate %{shown} di %{total}"
none: Nessuna sessione trovata con questi filtri.
filters:
q: Cerca
q_placeholder: Società, squadra, avversario…
status: Stato
platform: Piattaforma
club: Società
node: Nodo ingest
from: Da
to: A
any: Tutti
apply: Filtra
reset: Azzera
table:
club: Società
match: Match
status: Stato
started: Inizio
ended: Fine
duration: Durata
ingest: Ingest
disconnects: Disconnessioni
link: Link
detail: Dettaglio
regia: Regia
show:
title: "Sessione %{id}"
heading: Dettaglio sessione
back: "← Torna alle sessioni"
club_label: "Società:"
summary_title: Riepilogo
timing_title: Tempi
ingest_title: Ingest e media
devices_title: Dispositivi
events_title: Eventi
events_none: Nessun evento registrato.
status_label: "Stato:"
stop_button: Termina sessione
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
match_label: "Match: %{team} vs %{opponent}"
youtube_studio: YouTube Studio
broadcast: Broadcast
open_replays: Apri replay società
audio_muted: Audio muto
audio_on: Audio attivo
ingest_node: "Nodo ingest:"
rtmp_ingest: "RTMP ingest:"
events_title: Eventi
fields:
id: ID
club: Società
team: Squadra
opponent: Avversario
operator: Operatore
platform: Piattaforma
privacy: Privacy
quality: Qualità
min_quality: Qualità minima
audio: Audio
created: Creata
started: Inizio
ended: Fine
duration: Durata
disconnects: Disconnessioni
scheduled: Partita programmata
location: Sede
node: Nodo
rtmp: RTMP ingest
hls: HLS
recording: Replay
devices:
role: Ruolo
network: Rete
battery: Batteria
bitrate: Bitrate
fps: FPS
last_seen: Ultimo contatto
table:
type: Tipo
when: Quando
meta: Meta
meta: Dettagli
links:
watch_title: Link diretta
none: Nessun link video disponibile per questa sessione.
@@ -298,6 +365,54 @@ it:
active_exists: Esiste già un link regia attivo (lURL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.
none_active: Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.
generate_button: Genera link regia
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
analytics:
missing_path: Seleziona una pagina.
filters:
from: Da
to: A
device: Device
any: Tutti
apply: Filtra
reset: Azzera
layer: Livello
layer_move: Movimenti mouse
layer_click: Click
index:
title: Analytics sito
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
none: Nessun dato nel periodo selezionato.
heatmap: Heatmap
table:
path: Pagina
pageviews: Pageview
moves: Movimenti
clicks: Click
avg_scroll: Scroll medio
max_scroll: Scroll max
show:
title: Heatmap pagina
back: "← Torna ad Analytics"
summary: Riepilogo
scroll_depth: Profondità di scroll
scroll_hint: "Media %{avg}% · massimo %{max}%"
heatmap_moves: Mappa movimenti mouse
heatmap_clicks: Mappa click
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
preview_title: Screenshot pagina
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo.
snapshot_meta: "Screenshot %{device} · catturato %{at}"
no_points: Nessun punto aggregato per questo livello nel periodo.
heatmap_title: Mappa click
no_clicks: Nessun click aggregato per questa pagina nel periodo.
billing:
index:
title: Pagamenti da fatturare
@@ -398,3 +513,68 @@ it:
club_recordings:
index:
title: "Replay — %{club}"
announcements:
index:
title: Avvisi
lead: Messaggi verso le app, il sito pubblico e l'area riservata — manutenzione, aggiornamenti o comunicazioni.
new: Nuovo avviso
table_title: Avvisi
none: Nessun avviso. Crea il primo per notificare app e sito.
new:
title: Nuovo avviso
edit:
title: Modifica avviso
table:
status: Stato
kind: Tipo
title: Messaggio
channels: Destinazioni
window: Finestra
actions:
edit: Modifica
delete: Elimina
delete_confirm: Eliminare questo avviso? Non sarà più visibile nelle destinazioni scelte.
kind:
info: Informazione
maintenance: Manutenzione
update: Aggiornamento app
severity:
info: Info
warning: Attenzione
critical: Critico
status:
draft: Bozza
published: Pubblicato
archived: Archiviato
channels:
android: App Android
ios: App iOS
web_public: Sito pubblico
web_private: Area riservata
window:
always: Sempre (finché pubblicato)
from: "Da %{time}"
until: "Fino a %{time}"
form:
kind: Tipo
severity: Gravità
status: Stato
channels: Dove mostrarlo
channels_hint: "Puoi selezionare una o più destinazioni. Android e iOS sono indipendenti: se il testo deve essere diverso, crea due avvisi. L'area riservata è visibile dopo il login (società, squadre, account)."
title: Titolo
body: Testo
starts_at: Inizio visibilità
ends_at: Fine visibilità
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
action_url: Link (facoltativo)
action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store della piattaforma (Play Store su Android, App Store su iOS).
action_label: Etichetta del pulsante
locales_legend: Testo per lingua
locales_hint: Compila tutte le lingue dell'app (italiano, inglese, francese, tedesco, spagnolo). L'italiano è obbligatorio; se una lingua manca, l'app usa inglese e poi italiano.
locale_required: obbligatorio
dismissible: L'utente può chiudere l'avviso
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
submit_create: Salva avviso
submit_update: Aggiorna avviso
cancel: Annulla
+11
View File
@@ -21,6 +21,10 @@ de:
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
too_long: "ist zu lang (höchstens %{count} Zeichen)"
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
app_announcement:
no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
translation_too_long: "Übersetzung %{locale}: ein Feld ist zu lang."
translation_invalid_url: "Übersetzung %{locale}: der Link muss eine http(s)-URL sein."
club:
back_to_club: "← Verein"
sport_label: Hauptsportart
@@ -700,6 +704,13 @@ de:
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
contacts:
sent: Nachricht gesendet. Wir antworten an die angegebene E-Mail-Adresse.
throttled: Du hast zu viele Anfragen gesendet. Bitte versuche es in einer Stunde erneut.
invalid: Prüfe die Pflichtfelder und versuche es erneut.
invalid_email: Gib eine gültige E-Mail-Adresse ein.
invalid_message: Die Nachricht ist zu kurz oder zu lang.
privacy_required: Zum Senden des Formulars musst du die Datenschutzerklärung akzeptieren.
accounts:
name_required: Der Name ist erforderlich
profile_updated: Profil aktualisiert.
+13
View File
@@ -16,6 +16,10 @@ en:
too_short: "is too short (minimum is %{count} characters)"
too_long: "is too long (maximum is %{count} characters)"
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
app_announcement:
no_channel: Select at least one destination (apps, public website or logged-in area).
translation_too_long: "Translation %{locale}: a field is too long."
translation_invalid_url: "Translation %{locale}: the link must be an http(s) URL."
club:
back_to_club: "← Club"
sport_label: Main sport
@@ -39,6 +43,8 @@ en:
cover_file_label: Cover image
cover_preview_alt: Live cover preview
cover_remove_label: Remove custom cover
cover_reset_button: Restore standard cover
cover_reset_pending_hint: The standard cover will be restored when you save.
cover_requires_full: Custom sponsor covers are available on the Premium Full plan.
cover_see_plans: See plans
dashboard:
@@ -703,6 +709,13 @@ en:
password_same_as_current: New password must be different from the current password
password_mismatch: Passwords don't match
password_updated: Password updated. You can now log in.
contacts:
sent: Message sent. We will reply to the email you provided.
throttled: You have sent too many requests. Please try again in an hour.
invalid: Check the required fields and try again.
invalid_email: Enter a valid email address.
invalid_message: The message is too short or too long.
privacy_required: To send the form you must accept the privacy notice.
accounts:
name_required: Name is required
profile_updated: Profile updated.
+11
View File
@@ -21,6 +21,10 @@ es:
too_short: "es demasiado corta (mínimo %{count} caracteres)"
too_long: "es demasiado larga (máximo %{count} caracteres)"
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
app_announcement:
no_channel: Elige al menos un destino (apps, sitio público o área privada).
translation_too_long: "Traducción %{locale}: un campo es demasiado largo."
translation_invalid_url: "Traducción %{locale}: el enlace debe ser una URL http(s)."
club:
back_to_club: "← Club"
sport_label: Deporte principal
@@ -700,6 +704,13 @@ es:
password_same_as_current: La nueva contraseña debe ser distinta de la actual
password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
contacts:
sent: Mensaje enviado. Te responderemos al email indicado.
throttled: Has enviado demasiadas solicitudes. Inténtalo de nuevo dentro de una hora.
invalid: Revisa los campos obligatorios e inténtalo de nuevo.
invalid_email: Introduce una dirección de email válida.
invalid_message: El mensaje es demasiado corto o demasiado largo.
privacy_required: Para enviar el formulario debes aceptar la política de privacidad.
accounts:
name_required: El nombre es obligatorio
profile_updated: Perfil actualizado.
+11
View File
@@ -21,6 +21,10 @@ fr:
too_short: "est trop court (minimum %{count} caractères)"
too_long: "est trop long (maximum %{count} caractères)"
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
app_announcement:
no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
translation_too_long: "Traduction %{locale} : un champ est trop long."
translation_invalid_url: "Traduction %{locale} : le lien doit être une URL http(s)."
club:
back_to_club: "← Club"
sport_label: Sport principal
@@ -700,6 +704,13 @@ fr:
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
contacts:
sent: Message envoyé. Nous te répondrons à l'email indiqué.
throttled: Tu as envoyé trop de demandes. Réessaie dans une heure.
invalid: Vérifie les champs obligatoires et réessaie.
invalid_email: Saisis une adresse email valide.
invalid_message: Le message est trop court ou trop long.
privacy_required: Pour envoyer le formulaire tu dois accepter la notice de confidentialité.
accounts:
name_required: Le nom est obligatoire
profile_updated: Profil mis à jour.
+13
View File
@@ -16,6 +16,10 @@ it:
too_short: "è troppo corta (minimo %{count} caratteri)"
too_long: "è troppo lunga (massimo %{count} caratteri)"
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
app_announcement:
no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
translation_too_long: "Traduzione %{locale}: un campo supera la lunghezza massima."
translation_invalid_url: "Traduzione %{locale}: il link deve essere un URL http(s)."
club:
back_to_club: "← Società"
sport_label: Sport principale
@@ -39,6 +43,8 @@ it:
cover_file_label: Immagine copertina
cover_preview_alt: Anteprima copertina diretta
cover_remove_label: Rimuovi copertina personalizzata
cover_reset_button: Ripristina copertina standard
cover_reset_pending_hint: Verrà ripristinata la copertina standard al salvataggio.
cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full.
cover_see_plans: Vedi piani
dashboard:
@@ -729,6 +735,13 @@ it:
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_mismatch: Le password non coincidono
password_updated: Password aggiornata. Ora puoi accedere.
contacts:
sent: Messaggio inviato. Ti rispondiamo all'email indicata.
throttled: Hai inviato troppe richieste. Riprova tra un'ora.
invalid: Controlla i campi obbligatori e riprova.
invalid_email: Inserisci un indirizzo email valido.
invalid_message: Il messaggio è troppo corto o troppo lungo.
privacy_required: Per inviare il modulo devi accettare l'informativa privacy.
accounts:
name_required: Il nome è obbligatorio
profile_updated: Profilo aggiornato.
+10 -2
View File
@@ -28,6 +28,7 @@ de:
s3_item4_html: "<strong>Audiovisuelle Inhalte:</strong> Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
s3_item5_html: "<strong>Zahlungsdaten:</strong> Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
s3_item6_html: "<strong>Nutzungsdaten:</strong> Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
s3_item7_html: "<strong>Daten des Kontaktformulars:</strong> Name, E-Mail, optionaler Verein, Anliegen und Nachricht von der Kontaktseite."
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare <strong>minderjährige Athleten</strong> erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
@@ -56,6 +57,8 @@ de:
s5_row5_basis: Vertragserfüllung
s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung)
s5_row6_basis: Einwilligung oder berechtigtes Interesse, je nach Fall
s5_row7_purpose: Bearbeitung von Anfragen über das Kontaktformular
s5_row7_basis: Vorvertragliche Maßnahmen; berechtigtes Interesse an der Beantwortung von Anfragen
s6_title: 6. Empfänger und Auftragsverarbeiter
s6_intro: "Die Daten können an Dienstleister weitergegeben werden, die als Auftragsverarbeiter tätig sind, unter anderem:"
s6_item1: Anbieter von Hosting- und Cloud-/Serverinfrastruktur;
@@ -72,6 +75,7 @@ de:
s8_item2_html: "<strong>Spielaufzeichnungen (Replays):</strong> je nach Paket (z. B. 30 oder 90 Tage), danach automatische Löschung oder Löschung auf Anfrage."
s8_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 3090 Tage), sofern keine Sicherheitspflichten entgegenstehen."
s8_item4_html: "<strong>Token zum Zurücksetzen des Passworts:</strong> maximale Gültigkeit %{hours} Stunden."
s8_item5_html: "<strong>Anfragen über das Kontaktformular:</strong> nur so lange gespeichert, wie zur Bearbeitung der Anfrage erforderlich, danach aus dem betrieblichen Postfach gelöscht."
s9_title: 9. Rechte der betroffenen Personen
s9_intro: "Als betroffene Person haben Sie das Recht:"
s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie;
@@ -84,7 +88,7 @@ de:
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."
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> für aggregierte Statistiken."
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_cookie_policy_link_text: Cookie-Richtlinie
s12_title: 12. Änderungen
@@ -165,7 +169,7 @@ de:
s4_1_row2_duration: 12 Monate
s4_1_row2_provider: Match Live TV (First-Party)
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."
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_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet.
table2_col_name: Name
@@ -181,6 +185,10 @@ de:
s4_2_row3_purpose: Unterscheidet Nutzer (Statistiken)
s4_2_row3_duration: 24 Stunden
s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / Scroll (First-Party)"
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_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_google_privacy_link_text: Google-Datenschutzerklärung
s4_2_google_optout_link_text: Analytics-Deaktivierungs-Add-on
+10 -2
View File
@@ -28,6 +28,7 @@ en:
s3_item4_html: "<strong>Audiovisual content:</strong> video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
s3_item5_html: "<strong>Payment data:</strong> for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
s3_item6_html: "<strong>Browsing data:</strong> server logs, IP address, user agent, needed for the security and operation of the site."
s3_item7_html: "<strong>Contact form data:</strong> name, email, optional club, topic and message submitted from the Contact page."
s4_title: 4. Processing of images and data of minor athletes
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable <strong>minor athletes</strong> (image, voice, jersey number, game data)."
s4_lead1: Who is responsible for processing images of minors
@@ -56,6 +57,8 @@ en:
s5_row5_basis: Performance of a contract
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
s5_row6_basis: Consent or legitimate interest, depending on the case
s5_row7_purpose: Handling requests sent via the contact form
s5_row7_basis: Pre-contractual measures; legitimate interest in answering enquiries
s6_title: 6. Recipients and data processors
s6_intro: "Data may be shared with providers acting as data processors, including:"
s6_item1: hosting and cloud/server infrastructure provider;
@@ -72,6 +75,7 @@ en:
s8_item2_html: "<strong>Match recordings (replays):</strong> according to the plan (e.g. 30 or 90 days), then automatic deletion or deletion on request."
s8_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 3090 days), unless security obligations require otherwise."
s8_item4_html: "<strong>Password reset token:</strong> maximum validity %{hours} hours."
s8_item5_html: "<strong>Contact form enquiries:</strong> kept only for as long as needed to handle the request, then deleted from the operational mailbox."
s9_title: 9. Data subject rights
s9_intro: "As a data subject you have the right to:"
s9_item1: access your data and obtain a copy of it;
@@ -84,7 +88,7 @@ en:
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."
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> for aggregated statistics."
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_cookie_policy_link_text: Cookie policy
s12_title: 12. Changes
@@ -165,7 +169,7 @@ en:
s4_1_row2_duration: 12 months
s4_1_row2_provider: Match Live TV (first-party)
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."
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_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server.
table2_col_name: Name
@@ -181,6 +185,10 @@ en:
s4_2_row3_purpose: Distinguishes users (statistics)
s4_2_row3_duration: 24 hours
s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (first-party)"
s4_2_row4_purpose: Aggregated mouse-move, click and scroll-depth counts, plus page screenshots for heatmaps (no user identifiers)
s4_2_row4_duration: Aggregates kept up to 30 days
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_google_privacy_link_text: Google Privacy Policy
s4_2_google_optout_link_text: Analytics opt-out add-on
+10 -2
View File
@@ -28,6 +28,7 @@ es:
s3_item4_html: "<strong>Contenidos audiovisuales:</strong> flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
s3_item5_html: "<strong>Datos de pago:</strong> para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
s3_item6_html: "<strong>Datos de navegación:</strong> registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
s3_item7_html: "<strong>Datos del formulario de contacto:</strong> nombre, email, club opcional, motivo y mensaje enviados desde la página de Contacto."
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a <strong>deportistas menores de edad</strong> identificables (imagen, voz, número de camiseta, datos de juego)."
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
@@ -56,6 +57,8 @@ es:
s5_row5_basis: Ejecución del contrato
s5_row6_purpose: Marketing directo (solo si está previsto y con consentimiento cuando se requiera)
s5_row6_basis: Consentimiento o interés legítimo, según los casos
s5_row7_purpose: Gestión de las solicitudes enviadas desde el formulario de contacto
s5_row7_basis: Medidas precontractuales; interés legítimo en responder a las solicitudes
s6_title: 6. Destinatarios y encargados del tratamiento
s6_intro: "Los datos pueden comunicarse a proveedores que actúan como encargados del tratamiento, entre ellos:"
s6_item1: proveedor de alojamiento e infraestructura en la nube/servidor;
@@ -72,6 +75,7 @@ es:
s8_item2_html: "<strong>Grabaciones de partidos (repeticiones):</strong> según el plan (por ejemplo, 30 o 90 días), después eliminación automática o a petición."
s8_item3_html: "<strong>Registros técnicos:</strong> periodo limitado (por ejemplo, 30-90 días) salvo obligaciones de seguridad."
s8_item4_html: "<strong>Token de restablecimiento de contraseña:</strong> validez máxima %{hours} horas."
s8_item5_html: "<strong>Solicitudes del formulario de contacto:</strong> se conservan solo el tiempo necesario para gestionar la solicitud y luego se eliminan del buzón operativo."
s9_title: 9. Derechos de los interesados
s9_intro: "Como interesado tienes derecho a:"
s9_item1: acceder a tus datos y obtener una copia de ellos;
@@ -84,7 +88,7 @@ es:
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."
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> para estadísticas agregadas."
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_cookie_policy_link_text: política de cookies
s12_title: 12. Modificaciones
@@ -165,7 +169,7 @@ es:
s4_1_row2_duration: 12 meses
s4_1_row2_provider: Match Live TV (propia)
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."
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_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor.
table2_col_name: Nombre
@@ -181,6 +185,10 @@ es:
s4_2_row3_purpose: Distingue a los usuarios (estadísticas)
s4_2_row3_duration: 24 horas
s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (propia)"
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_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_google_privacy_link_text: Política de privacidad de Google
s4_2_google_optout_link_text: complemento de exclusión de Analytics
+10 -2
View File
@@ -28,6 +28,7 @@ fr:
s3_item4_html: "<strong>Contenus audiovisuels :</strong> flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
s3_item5_html: "<strong>Données de paiement :</strong> pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
s3_item6_html: "<strong>Données de navigation :</strong> journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
s3_item7_html: "<strong>Données du formulaire de contact :</strong> nom, email, club éventuel, motif et message envoyés depuis la page Contact."
s4_title: 4. Traitement des images et données des athlètes mineurs
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des <strong>athlètes mineurs</strong> identifiables (image, voix, numéro de maillot, données de jeu)."
s4_lead1: Qui est responsable du traitement des images des mineurs
@@ -56,6 +57,8 @@ fr:
s5_row5_basis: Exécution du contrat
s5_row6_purpose: Marketing direct (uniquement si prévu et avec consentement le cas échéant)
s5_row6_basis: Consentement ou intérêt légitime, selon les cas
s5_row7_purpose: Traitement des demandes envoyées via le formulaire de contact
s5_row7_basis: Mesures précontractuelles ; intérêt légitime à répondre aux demandes
s6_title: 6. Destinataires et sous-traitants
s6_intro: "Les données peuvent être communiquées à des prestataires agissant en tant que sous-traitants, notamment :"
s6_item1: fournisseur d'hébergement et d'infrastructure cloud/serveur ;
@@ -72,6 +75,7 @@ fr:
s8_item2_html: "<strong>Enregistrements des matchs (replays) :</strong> selon le forfait (par ex. 30 ou 90 jours), puis suppression automatique ou sur demande."
s8_item3_html: "<strong>Journaux techniques :</strong> durée limitée (par ex. 30 à 90 jours) sauf obligations de sécurité."
s8_item4_html: "<strong>Jeton de réinitialisation du mot de passe :</strong> validité maximale %{hours} heures."
s8_item5_html: "<strong>Demandes du formulaire de contact :</strong> conservées uniquement le temps nécessaire au traitement de la demande, puis supprimées de la boîte mail opérationnelle."
s9_title: 9. Droits des personnes concernées
s9_intro: "En tant que personne concernée, vous avez le droit :"
s9_item1: d'accéder à vos données et d'en obtenir une copie ;
@@ -84,7 +88,7 @@ fr:
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."
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> pour des statistiques agrégées."
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_cookie_policy_link_text: politique de cookies
s12_title: 12. Modifications
@@ -165,7 +169,7 @@ fr:
s4_1_row2_duration: 12 mois
s4_1_row2_provider: Match Live TV (première partie)
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."
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_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur.
table2_col_name: Nom
@@ -181,6 +185,10 @@ fr:
s4_2_row3_purpose: Distingue les utilisateurs (statistiques)
s4_2_row3_duration: 24 heures
s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (première partie)"
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_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_google_privacy_link_text: Politique de confidentialité Google
s4_2_google_optout_link_text: module de désactivation Analytics
+10 -2
View File
@@ -28,6 +28,7 @@ it:
s3_item4_html: "<strong>Contenuti audiovisivi:</strong> flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato."
s3_item5_html: "<strong>Dati di pagamento:</strong> per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta."
s3_item6_html: "<strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
s3_item7_html: "<strong>Dati del modulo contatti:</strong> nome, email, eventuale società, motivo e messaggio inviati dalla pagina Contatti."
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
s4_p1_html: "Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18). Le dirette possono riprendere <strong>atleti minorenni</strong> identificabili (immagine, voce, numero di maglia, dati di gioco)."
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
@@ -56,6 +57,8 @@ it:
s5_row5_basis: Esecuzione del contratto
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
s5_row7_purpose: Gestione delle richieste inviate dal modulo contatti
s5_row7_basis: Misure precontrattuali; legittimo interesse a rispondere alle richieste
s6_title: 6. Destinatari e responsabili del trattamento
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
s6_item1: fornitore di hosting e infrastruttura cloud/server;
@@ -72,6 +75,7 @@ it:
s8_item2_html: "<strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta."
s8_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 3090 giorni) salvo obblighi di sicurezza."
s8_item4_html: "<strong>Token reset password:</strong> validità massima %{hours} ore."
s8_item5_html: "<strong>Richieste dal modulo contatti:</strong> conservate solo per il tempo necessario a gestire la richiesta, poi eliminate dalla casella di posta operativa."
s9_title: 9. Diritti degli interessati
s9_intro: "In qualità di interessato hai diritto di:"
s9_item1: accedere ai tuoi dati e ottenerne copia;
@@ -84,7 +88,7 @@ it:
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."
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> per statistiche aggregate."
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_cookie_policy_link_text: Cookie policy
s12_title: 12. Modifiche
@@ -165,7 +169,7 @@ it:
s4_1_row2_duration: 12 mesi
s4_1_row2_provider: Match Live TV (prima parte)
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."
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_inactive: Google Analytics è configurato solo quando il titolare imposta lID misurazione sul server.
table2_col_name: Nome
@@ -181,6 +185,10 @@ it:
s4_2_row3_purpose: Distingue gli utenti (statistiche)
s4_2_row3_duration: 24 ore
s4_2_row3_provider: Google
s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
s4_2_row4_purpose: Conteggi aggregati di movimenti mouse, click e profondità di scroll, più screenshot della pagina per le heatmaps (nessun identificativo utente)
s4_2_row4_duration: Aggregati fino a 30 giorni
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_google_privacy_link_text: Privacy Policy Google
s4_2_google_optout_link_text: componente opt-out Analytics
+17
View File
@@ -9,6 +9,23 @@ de:
expiry_html: "Der Link ist %{hours} Stunden gültig. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail — dein Passwort wird nicht geändert."
ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.
footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Vertriebsanfrage — %{name}"
support: "Supportanfrage — %{name}"
privacy: "Datenschutzanfrage — %{name}"
heading: Neue Anfrage von der Website
name: "Name:"
email: "E-Mail:"
club: "Verein:"
club_none: Nicht angegeben
topic: "Anliegen:"
topic_labels:
commercial: Vertrieb / Angebot
support: Technischer Support
privacy: Datenschutz / DSGVO
message: "Nachricht:"
footer: Match Live TV — Kontaktseite
replay_ready:
subject: "Replay bereit — %{title}"
hello: "Hallo %{name},"
+17
View File
@@ -9,6 +9,23 @@ en:
expiry_html: "The link is valid for %{hours} hours. If you did not request this reset, ignore this email — your password will not change."
ignore: If you did not request this reset, ignore this email.
footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Sales enquiry — %{name}"
support: "Support enquiry — %{name}"
privacy: "Privacy enquiry — %{name}"
heading: New website enquiry
name: "Name:"
email: "Email:"
club: "Club:"
club_none: Not provided
topic: "Topic:"
topic_labels:
commercial: Sales / quote
support: Technical support
privacy: Privacy / GDPR
message: "Message:"
footer: Match Live TV — Contact page
replay_ready:
subject: "Replay ready — %{title}"
hello: "Hi %{name},"
+17
View File
@@ -9,6 +9,23 @@ es:
expiry_html: "El enlace es válido durante %{hours} horas. Si no solicitaste el restablecimiento, ignora este correo: la contraseña no se modificará."
ignore: Si no solicitaste el restablecimiento, ignora este correo.
footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Consulta comercial — %{name}"
support: "Consulta de asistencia — %{name}"
privacy: "Consulta de privacidad — %{name}"
heading: Nueva solicitud desde el sitio
name: "Nombre:"
email: "Email:"
club: "Club:"
club_none: No indicado
topic: "Motivo:"
topic_labels:
commercial: Comercial / presupuesto
support: Asistencia técnica
privacy: Privacidad / RGPD
message: "Mensaje:"
footer: Match Live TV — página de Contacto
replay_ready:
subject: "Replay listo — %{title}"
hello: "Hola %{name},"
+17
View File
@@ -9,6 +9,23 @@ fr:
expiry_html: "Le lien est valable %{hours} heures. Si vous navez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié."
ignore: Si vous navez pas demandé cette réinitialisation, ignorez cet e-mail.
footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Demande commerciale — %{name}"
support: "Demande d'assistance — %{name}"
privacy: "Demande privacy — %{name}"
heading: Nouvelle demande depuis le site
name: "Nom :"
email: "Email :"
club: "Club :"
club_none: Non indiqué
topic: "Motif :"
topic_labels:
commercial: Commercial / devis
support: Assistance technique
privacy: Confidentialité / RGPD
message: "Message :"
footer: Match Live TV — page Contact
replay_ready:
subject: "Replay prêt — %{title}"
hello: "Bonjour %{name},"
+17
View File
@@ -9,6 +9,23 @@ it:
expiry_html: "Il link è valido per %{hours} ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata."
ignore: Se non hai richiesto tu il reset, ignora questa email.
footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Richiesta commerciale — %{name}"
support: "Richiesta assistenza — %{name}"
privacy: "Richiesta privacy — %{name}"
heading: Nuova richiesta dal sito
name: "Nome:"
email: "Email:"
club: "Società:"
club_none: Non indicata
topic: "Motivo:"
topic_labels:
commercial: Commerciale / preventivo
support: Assistenza tecnica
privacy: Privacy / GDPR
message: "Messaggio:"
footer: Match Live TV — pagina Contatti
replay_ready:
subject: "Replay pronto — %{title}"
hello: "Ciao %{name},"
+37
View File
@@ -175,3 +175,40 @@ de:
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
cta_start: Kostenlos starten
cta_faq: Häufige Fragen
contacts:
meta_title: "Kontakt — Match Live TV"
meta_description: "Kontakt zu Match Live TV: Angebote für Sportvereine, technischer Support und Datenschutzanfragen. Sitz in Mailand."
eyebrow: Match Live TV
title: Kontakt
lead: Schreib uns für ein Angebot, App-Support oder eine Datenschutzanfrage. Wir antworten in der Regel innerhalb eines Werktags.
response_time: "Typische Antwortzeit: 1 Werktag"
commercial_title: Vertrieb
commercial_body: Maßgeschneiderte Tarife, mehrere Teams, Turniere oder Integrationen. Schreib an
support_title: Support
support_body_html: "Probleme mit Zugang, Livestream oder App. Sieh zuerst die %{faq_link} oder die Seite %{support_link} an oder schreib an"
support_link: Support
faq_link: FAQ
privacy_title: Datenschutz
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
company_title: Sitz
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
label_address: Adresse
label_vat: USt-IdNr.
form_title: Nachricht senden
form_lead: "Fülle das Formular aus: Wir antworten an die angegebene E-Mail-Adresse."
name_label: Name
email_label: E-Mail
club_label: Sportverein (optional)
topic_label: Anliegen
topic_commercial: Vertrieb / Angebot
topic_support: Technischer Support
topic_privacy: Datenschutz / DSGVO
message_label: Nachricht
honeypot_label: Website
privacy_accept_html: Ich stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu, wie in der %{privacy_link} beschrieben.
privacy_link: Datenschutzerklärung
submit: Nachricht senden
cta_title: Lieber selbst nachsehen?
cta_body: Viele Antworten stehen bereits in den FAQ. Um mit dem Streamen zu starten, registriere den Verein.
cta_faq: FAQ
cta_signup: Team registrieren
+37
View File
@@ -175,3 +175,40 @@ en:
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
cta_start: Start for free
cta_faq: Frequently asked questions
contacts:
meta_title: "Contact — Match Live TV"
meta_description: "Contact Match Live TV: quotes for sports clubs, technical support and privacy requests. Based in Milan."
eyebrow: Match Live TV
title: Contact
lead: Write to us for a quote, app support or a privacy request. We usually reply within one working day.
response_time: "Typical reply time: 1 working day"
commercial_title: Sales
commercial_body: Custom plans, multiple teams, tournaments or integrations. Write to
support_title: Support
support_body_html: "Sign-in, live stream or app issues. Check the %{faq_link} or the %{support_link} page first, or write to"
support_link: Support
faq_link: FAQ
privacy_title: Privacy
privacy_body: GDPR rights, consent and personal data. Write to
company_title: Company details
company_lead: Service provider and data controller.
label_address: Address
label_vat: VAT number
form_title: Send a message
form_lead: "Fill in the form and we will reply to the email you provide."
name_label: Name
email_label: Email
club_label: Sports club (optional)
topic_label: Topic
topic_commercial: Sales / quote
topic_support: Technical support
topic_privacy: Privacy / GDPR
message_label: Message
honeypot_label: Website
privacy_accept_html: I agree to the processing of my data to handle this request, as described in the %{privacy_link}.
privacy_link: privacy notice
submit: Send message
cta_title: Prefer to look it up yourself?
cta_body: Many answers are already in the FAQ. To start streaming, register your club.
cta_faq: FAQ
cta_signup: Register your team
+37
View File
@@ -175,3 +175,40 @@ es:
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
cta_start: Empieza gratis
cta_faq: Preguntas frecuentes
contacts:
meta_title: "Contacto — Match Live TV"
meta_description: "Contacta con Match Live TV: presupuestos para clubes deportivos, soporte técnico y solicitudes de privacidad. Sede en Milán."
eyebrow: Match Live TV
title: Contacto
lead: Escríbenos para un presupuesto, para ayuda con la app o para una solicitud de privacidad. Solemos responder en un día laborable.
response_time: "Tiempo de respuesta orientativo: 1 día laborable"
commercial_title: Comercial
commercial_body: Planes a medida, varios equipos, torneos o integraciones. Escribe a
support_title: Asistencia
support_body_html: "Problemas de acceso, directos o app. Consulta primero las %{faq_link} o la página %{support_link}, o escribe a"
support_link: Soporte
faq_link: FAQ
privacy_title: Privacidad
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
company_title: Sede
company_lead: Prestador del servicio y responsable del tratamiento.
label_address: Dirección
label_vat: NIF / IVA
form_title: Enviar un mensaje
form_lead: "Completa el formulario: te responderemos al email que indiques."
name_label: Nombre
email_label: Email
club_label: Club deportivo (opcional)
topic_label: Motivo
topic_commercial: Comercial / presupuesto
topic_support: Asistencia técnica
topic_privacy: Privacidad / RGPD
message_label: Mensaje
honeypot_label: Sitio web
privacy_accept_html: Acepto el tratamiento de los datos para gestionar esta solicitud, como se describe en la %{privacy_link}.
privacy_link: política de privacidad
submit: Enviar mensaje
cta_title: "¿Prefieres buscarlo tú?"
cta_body: Muchas respuestas ya están en las FAQ. Para empezar a emitir, registra el club.
cta_faq: FAQ
cta_signup: Registrar el equipo
+37
View File
@@ -175,3 +175,40 @@ fr:
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
cta_start: Commencer gratuitement
cta_faq: Questions fréquentes
contacts:
meta_title: "Contact — Match Live TV"
meta_description: "Contactez Match Live TV : devis pour clubs sportifs, assistance technique et demandes privacy. Siège à Milan."
eyebrow: Match Live TV
title: Contact
lead: Écrivez-nous pour un devis, pour l'assistance de l'app ou pour une demande privacy. Nous répondons en général sous un jour ouvrable.
response_time: "Délai indicatif : 1 jour ouvrable"
commercial_title: Commercial
commercial_body: Offres sur mesure, plusieurs équipes, tournois ou intégrations. Écrivez à
support_title: Assistance
support_body_html: "Problèmes d'accès, de direct ou d'application. Consultez d'abord les %{faq_link} ou la page %{support_link}, ou écrivez à"
support_link: Support
faq_link: FAQ
privacy_title: Confidentialité
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
company_title: Siège
company_lead: Prestataire du service et responsable du traitement.
label_address: Adresse
label_vat: N° de TVA
form_title: Envoyer un message
form_lead: "Remplissez le formulaire : nous vous répondons à l'email indiqué."
name_label: Nom
email_label: Email
club_label: Club sportif (facultatif)
topic_label: Motif
topic_commercial: Commercial / devis
topic_support: Assistance technique
topic_privacy: Confidentialité / RGPD
message_label: Message
honeypot_label: Site web
privacy_accept_html: J'accepte le traitement des données pour gérer cette demande, comme décrit dans la %{privacy_link}.
privacy_link: notice de confidentialité
submit: Envoyer le message
cta_title: Vous préférez chercher vous-même ?
cta_body: Beaucoup de réponses sont déjà dans la FAQ. Pour commencer à diffuser, inscrivez le club.
cta_faq: FAQ
cta_signup: Enregistrer l'équipe
+37
View File
@@ -175,3 +175,40 @@ it:
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
cta_start: Inizia gratis
cta_faq: Domande frequenti
contacts:
meta_title: "Contatti — Match Live TV"
meta_description: "Contatta Match Live TV: preventivi per società sportive, assistenza tecnica e richieste privacy. Sede a Milano."
eyebrow: Match Live TV
title: Contatti
lead: Scrivici per un preventivo, per l'assistenza sull'app o per una richiesta privacy. Rispondiamo in genere entro un giorno lavorativo.
response_time: "Risposta indicativa: 1 giorno lavorativo"
commercial_title: Commerciale
commercial_body: Piani su misura, più squadre, tornei o integrazioni. Scrivi a
support_title: Assistenza
support_body_html: "Problemi di accesso, dirette o app. Consulta prima le %{faq_link} o la pagina %{support_link}, oppure scrivi a"
support_link: Supporto
faq_link: FAQ
privacy_title: Privacy
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
company_title: Sede
company_lead: Titolare del servizio e del trattamento dei dati.
label_address: Indirizzo
label_vat: Partita IVA
form_title: Invia un messaggio
form_lead: "Compila il modulo: ti rispondiamo all'email che indichi."
name_label: Nome
email_label: Email
club_label: Società sportiva (opzionale)
topic_label: Motivo
topic_commercial: Commerciale / preventivo
topic_support: Assistenza tecnica
topic_privacy: Privacy / GDPR
message_label: Messaggio
honeypot_label: Sito web
privacy_accept_html: Acconsento al trattamento dei dati per gestire questa richiesta, come descritto nella %{privacy_link}.
privacy_link: informativa privacy
submit: Invia messaggio
cta_title: Preferisci fare da solo?
cta_body: Molte risposte sono già nelle FAQ. Per iniziare a trasmettere, registra la società.
cta_faq: FAQ
cta_signup: Registra la squadra
+5
View File
@@ -141,6 +141,7 @@ de:
common:
privacy: Datenschutz
support: Support
contacts: Kontakt
cookies: Cookies
terms: AGB
pricing: Preise
@@ -174,3 +175,7 @@ de:
choice_youtube_hint_only: Entfernt nur das YouTube-Video
choice_both_label: Website und YouTube
choice_both_hint: Vollständige Löschung
announcement:
region: Hinweise
dismiss: Hinweis schließen
open: Öffnen
+5
View File
@@ -141,6 +141,7 @@ en:
common:
privacy: Privacy
support: Support
contacts: Contacts
cookies: Cookies
terms: Terms
pricing: Pricing
@@ -174,3 +175,7 @@ en:
choice_youtube_hint_only: Removes only the YouTube video
choice_both_label: Site and YouTube
choice_both_hint: Complete deletion
announcement:
region: Notices
dismiss: Dismiss notice
open: Open
+5
View File
@@ -141,6 +141,7 @@ es:
common:
privacy: Privacidad
support: Soporte
contacts: Contacto
cookies: Cookies
terms: Términos
pricing: Precios
@@ -174,3 +175,7 @@ es:
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
choice_both_label: Sitio y YouTube
choice_both_hint: Eliminación completa
announcement:
region: Avisos
dismiss: Cerrar aviso
open: Abrir
+5
View File
@@ -141,6 +141,7 @@ fr:
common:
privacy: Confidentialité
support: Support
contacts: Contacts
cookies: Cookies
terms: Conditions
pricing: Tarifs
@@ -174,3 +175,7 @@ fr:
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
choice_both_label: Site et YouTube
choice_both_hint: Suppression complète
announcement:
region: Alertes
dismiss: Fermer l'alerte
open: Ouvrir
+5
View File
@@ -141,6 +141,7 @@ it:
common:
privacy: Privacy
support: Supporto
contacts: Contatti
cookies: Cookie
terms: Termini
pricing: Prezzi
@@ -174,3 +175,7 @@ it:
choice_youtube_hint_only: Rimuove solo il video YouTube
choice_both_label: Da sito e YouTube
choice_both_hint: Eliminazione completa
announcement:
region: Avvisi
dismiss: Chiudi avviso
open: Apri
+11 -1
View File
@@ -16,6 +16,7 @@ Rails.application.routes.draw do
patch "account", to: "accounts#update"
patch "account/password", to: "accounts#password"
get "sports", to: "sports#index"
get "announcements", to: "announcements#index"
get "invitations/:token", to: "invitations#show"
post "invitations/:token/accept", to: "invitations#accept"
@@ -75,6 +76,9 @@ Rails.application.routes.draw do
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
post "analytics/events", to: "analytics/events#create"
post "analytics/snapshot", to: "analytics/snapshots#create"
namespace :admin do
get "login", to: "auth#new", as: :login
post "login", to: "auth#create"
@@ -84,6 +88,7 @@ Rails.application.routes.draw do
root to: "dashboard#index"
get "metrics", to: "dashboard#metrics"
resources :announcements, except: %i[show]
resources :ops, only: [:index], controller: "ops" do
member do
post :acknowledge
@@ -125,6 +130,8 @@ Rails.application.routes.draw do
delete :clear_kill_switch
end
end
get "analytics", to: "analytics#index", as: :analytics
get "analytics/page", to: "analytics#show", as: :analytics_page
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end
@@ -170,6 +177,9 @@ Rails.application.routes.draw do
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
get "prezzi", to: "pages#pricing", as: :prezzi
get "pricing", to: redirect("/prezzi")
get "contatti", to: "contacts#show", as: :contatti
post "contatti", to: "contacts#create"
get "contact", to: redirect("/contatti")
get "privacy", to: "pages#privacy", as: :privacy
get "support", to: "pages#support", as: :support
get "cookie", to: "pages#cookies", as: :cookies
@@ -179,7 +189,7 @@ Rails.application.routes.draw do
post "signup", to: "registrations#create"
get "login", to: "sessions#new"
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
match "logout", to: "sessions#destroy", via: %i[delete get]
patch "locale", to: "locales#update", as: :locale
get "password/forgot", to: "password_resets#new", as: :password_forgot
post "password/forgot", to: "password_resets#create"
@@ -0,0 +1,25 @@
class CreateAppAnnouncements < ActiveRecord::Migration[7.2]
def change
create_table :app_announcements, id: :uuid do |t|
t.string :kind, null: false, default: "info"
t.string :severity, null: false, default: "info"
t.string :status, null: false, default: "draft"
t.boolean :show_on_app, null: false, default: true
t.boolean :show_on_web_public, null: false, default: false
t.boolean :show_on_web_private, null: false, default: false
t.string :title, null: false
t.text :body, null: false
t.string :action_url
t.string :action_label
t.boolean :dismissible, null: false, default: true
t.datetime :starts_at
t.datetime :ends_at
t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
t.timestamps
end
add_index :app_announcements, :status
add_index :app_announcements, %i[status starts_at ends_at]
end
end
@@ -0,0 +1,26 @@
class SplitAnnouncementAppPlatforms < ActiveRecord::Migration[7.2]
def change
add_column :app_announcements, :show_on_android, :boolean, null: false, default: true
add_column :app_announcements, :show_on_ios, :boolean, null: false, default: true
add_column :app_announcements, :android_title, :string
add_column :app_announcements, :android_body, :text
add_column :app_announcements, :android_action_url, :string
add_column :app_announcements, :android_action_label, :string
add_column :app_announcements, :ios_title, :string
add_column :app_announcements, :ios_body, :text
add_column :app_announcements, :ios_action_url, :string
add_column :app_announcements, :ios_action_label, :string
reversible do |dir|
dir.up do
execute <<~SQL.squish
UPDATE app_announcements
SET show_on_android = show_on_app,
show_on_ios = show_on_app
SQL
end
end
remove_column :app_announcements, :show_on_app, :boolean, null: false, default: true
end
end
@@ -0,0 +1,5 @@
class AddAnnouncementTranslations < ActiveRecord::Migration[7.2]
def change
add_column :app_announcements, :translations, :jsonb, null: false, default: {}
end
end
@@ -0,0 +1,12 @@
class RemoveAnnouncementPlatformCopy < ActiveRecord::Migration[7.2]
def change
remove_column :app_announcements, :android_title, :string
remove_column :app_announcements, :android_body, :text
remove_column :app_announcements, :android_action_url, :string
remove_column :app_announcements, :android_action_label, :string
remove_column :app_announcements, :ios_title, :string
remove_column :app_announcements, :ios_body, :text
remove_column :app_announcements, :ios_action_url, :string
remove_column :app_announcements, :ios_action_label, :string
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
class CreateAnalyticsTables < ActiveRecord::Migration[7.2]
def change
create_table :analytics_events, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :page_path, null: false
t.string :device, null: false
t.string :event_type, null: false
t.decimal :x_pct, precision: 6, scale: 2
t.decimal :y_pct, precision: 6, scale: 2
t.decimal :scroll_pct, precision: 6, scale: 2
t.datetime :occurred_at, null: false
t.timestamps
end
add_index :analytics_events, :occurred_at
add_index :analytics_events, %i[event_type occurred_at]
create_table :analytics_page_cells, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.date :day, null: false
t.string :page_path, null: false
t.string :device, null: false
t.integer :cell_x, null: false
t.integer :cell_y, null: false
t.integer :click_count, null: false, default: 0
t.timestamps
end
add_index :analytics_page_cells,
%i[day page_path device cell_x cell_y],
unique: true,
name: "index_analytics_page_cells_unique"
create_table :analytics_page_stats, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.date :day, null: false
t.string :page_path, null: false
t.string :device, null: false
t.integer :pageview_count, null: false, default: 0
t.integer :scroll_samples, null: false, default: 0
t.integer :scroll_sum_pct, null: false, default: 0
t.integer :max_scroll_pct, null: false, default: 0
t.timestamps
end
add_index :analytics_page_stats,
%i[day page_path device],
unique: true,
name: "index_analytics_page_stats_unique"
end
end
@@ -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
@@ -0,0 +1,16 @@
# frozen_string_literal: true
class CreateAnalyticsPageSnapshots < ActiveRecord::Migration[7.2]
def change
create_table :analytics_page_snapshots, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.string :page_path, null: false
t.string :device, null: false
t.integer :width, null: false, default: 0
t.integer :height, null: false, default: 0
t.datetime :captured_at, null: false
t.timestamps
end
add_index :analytics_page_snapshots, %i[page_path device], unique: true, name: "index_analytics_page_snapshots_unique"
end
end
+78 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
ActiveRecord::Schema[7.2].define(version: 2026_08_20_220000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -51,6 +51,82 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
end
create_table "analytics_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "page_path", null: false
t.string "device", null: false
t.string "event_type", null: false
t.decimal "x_pct", precision: 6, scale: 2
t.decimal "y_pct", precision: 6, scale: 2
t.decimal "scroll_pct", precision: 6, scale: 2
t.datetime "occurred_at", null: false
t.datetime "created_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 ["occurred_at"], name: "index_analytics_events_on_occurred_at"
end
create_table "analytics_page_cells", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.date "day", null: false
t.string "page_path", null: false
t.string "device", null: false
t.integer "cell_x", null: false
t.integer "cell_y", null: false
t.integer "click_count", default: 0, null: false
t.datetime "created_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
end
create_table "analytics_page_snapshots", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "page_path", null: false
t.string "device", null: false
t.integer "width", default: 0, null: false
t.integer "height", default: 0, null: false
t.datetime "captured_at", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["page_path", "device"], name: "index_analytics_page_snapshots_unique", unique: true
end
create_table "analytics_page_stats", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.date "day", null: false
t.string "page_path", null: false
t.string "device", null: false
t.integer "pageview_count", default: 0, null: false
t.integer "scroll_samples", default: 0, null: false
t.integer "scroll_sum_pct", default: 0, null: false
t.integer "max_scroll_pct", default: 0, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["day", "page_path", "device"], name: "index_analytics_page_stats_unique", unique: true
end
create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "kind", default: "info", null: false
t.string "severity", default: "info", null: false
t.string "status", default: "draft", null: false
t.boolean "show_on_web_public", default: false, null: false
t.boolean "show_on_web_private", default: false, null: false
t.string "title", null: false
t.text "body", null: false
t.string "action_url"
t.string "action_label"
t.boolean "dismissible", default: true, null: false
t.datetime "starts_at"
t.datetime "ends_at"
t.uuid "created_by_admin_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.boolean "show_on_android", default: true, null: false
t.boolean "show_on_ios", default: true, null: false
t.jsonb "translations", default: {}, null: false
t.index ["created_by_admin_id"], name: "index_app_announcements_on_created_by_admin_id"
t.index ["status", "starts_at", "ends_at"], name: "index_app_announcements_on_status_and_starts_at_and_ends_at"
t.index ["status"], name: "index_app_announcements_on_status"
end
create_table "billing_club_quotes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.string "plan_slug", null: false
@@ -471,6 +547,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "app_announcements", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "clubs"
add_foreign_key "billing_invoices", "billing_payments"
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
namespace :analytics do
desc "Aggregate raw analytics events into heatmaps/stats"
task aggregate: :environment do
Analytics::AggregateJob.perform_now(purge: false)
end
desc "Purge analytics aggregates older than 30 days and leftover raw events"
task purge: :environment do
Analytics::Aggregate.new.purge_old!
end
end
+105
View File
@@ -0,0 +1,105 @@
/*! Admin heatmap overlay on captured page screenshot. */
(function () {
function colorFor(t) {
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 fit() {
var canvas = document.getElementById("admin-heatmap");
var stage = document.getElementById("admin-heatmap-stage");
var scaler = document.getElementById("admin-heatmap-scaler");
var shot = document.getElementById("admin-heatmap-shot");
if (!canvas || !stage) return;
var designW = 1200;
var designH = 900;
if (scaler) {
designW = parseInt(scaler.getAttribute("data-design-width"), 10) || designW;
designH = parseInt(scaler.getAttribute("data-design-height"), 10) || designH;
}
if (shot && shot.naturalWidth > 0) {
designW = shot.naturalWidth;
designH = shot.naturalHeight;
}
if (scaler) {
scaler.style.width = designW + "px";
scaler.style.height = designH + "px";
var stageW = Math.max(stage.clientWidth, 320);
var scale = Math.min(1, stageW / designW);
scaler.style.transform = "scale(" + scale + ")";
stage.style.height = Math.max(420, Math.round(designH * scale)) + "px";
} else {
designW = Math.max(stage.clientWidth, 320);
designH = Math.max(stage.clientHeight, 480);
}
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 = [];
}
canvas.width = designW;
canvas.height = designH;
canvas.style.width = designW + "px";
canvas.style.height = designH + "px";
var ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, designW, designH);
var cellW = designW / grid;
var cellH = designH / grid;
var radius = Math.max(cellW, cellH) * 2.2;
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 alpha = 0.28 + intensity * 0.55;
var grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, radius);
grd.addColorStop(
0,
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + "," + alpha + ")"
);
grd.addColorStop(
1,
"rgba(" + Math.round(rgb[0]) + "," + Math.round(rgb[1]) + "," + Math.round(rgb[2]) + ",0)"
);
ctx.fillStyle = grd;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, Math.PI * 2);
ctx.fill();
});
}
function boot() {
var shot = document.getElementById("admin-heatmap-shot");
fit();
if (shot) {
if (shot.complete && shot.naturalWidth > 0) fit();
else shot.addEventListener("load", fit);
}
window.addEventListener("resize", fit);
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
boot();
}
})();
+435
View File
@@ -206,6 +206,7 @@ body.admin-body {
.admin-table td {
padding: 0.65rem 0.5rem;
text-align: left;
vertical-align: middle;
border-bottom: 1px solid var(--card-border);
}
@@ -230,6 +231,23 @@ body.admin-body {
.badge--paused { background: #555; color: #fff; }
.badge--ready { background: #1b5e20; color: #c8e6c9; }
.badge--ok { background: #1b5e20; color: #c8e6c9; }
.badge--ingest-home { background: #1565c0; color: #e3f2fd; }
.badge--ingest-lab { background: #6a1b9a; color: #f3e5f5; }
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
.badge--ended { background: #37474f; color: #eceff1; }
.badge--error { background: #b71c1c; color: #ffebee; }
.admin-ingest {
display: inline-flex;
flex-direction: row;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
.admin-ingest__slug {
font-size: 0.85rem;
color: #ddd;
}
.team-list {
list-style: none;
@@ -307,9 +325,11 @@ body.admin-body {
word-break: break-all;
}
/* Flex sul contenuto, non sulla <td>: altrimenti si rompe vertical-align della riga. */
.admin-link-compact {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
}
@@ -582,3 +602,418 @@ body.admin-body {
color: var(--red) !important;
font-weight: 600;
}
.admin-form {
display: grid;
gap: 0.9rem;
max-width: 720px;
}
.admin-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.admin-form label {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 0.3rem;
}
.admin-form input:not([type="checkbox"]),
.admin-form select,
.admin-form textarea {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid #444;
border-radius: 6px;
background: #1a1a22;
color: #eee;
font: inherit;
}
.admin-form textarea {
min-height: 7rem;
resize: vertical;
}
.admin-form-hint {
margin: -0.4rem 0 0;
font-size: 0.8rem;
}
.admin-checkbox {
display: flex;
align-items: center;
gap: 0.55rem;
color: var(--text);
font-size: 0.9rem;
}
.admin-checkbox input[type="checkbox"] {
width: 1rem;
height: 1rem;
}
.admin-channels {
margin: 0;
padding: 0.85rem 1rem;
border: 1px solid #444;
border-radius: 8px;
display: grid;
gap: 0.55rem;
}
.admin-channels legend {
padding: 0 0.35rem;
font-size: 0.8rem;
color: var(--muted);
}
.admin-locale-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.admin-locale-tab {
background: #2a2a2a;
border: 1px solid #444;
color: var(--text);
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.admin-locale-tab.is-active {
border-color: #ff2d2d;
background: #3a1515;
}
.admin-locale-panel {
display: grid;
gap: 0.75rem;
}
.admin-locale-panel[hidden] {
display: none;
}
.admin-locale-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
}
.admin-locale-tab {
background: #2a2a2a;
border: 1px solid #444;
color: var(--text);
border-radius: 6px;
padding: 0.4rem 0.75rem;
cursor: pointer;
font-size: 0.85rem;
}
.admin-locale-tab.is-active {
border-color: #ff2d2d;
background: #3a1515;
}
.admin-locale-panel {
display: grid;
gap: 0.75rem;
}
.admin-locale-panel[hidden] {
display: none;
}
.admin-form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.35rem;
}
@media (max-width: 720px) {
.admin-form-row {
grid-template-columns: 1fr;
}
}
.admin-sessions-filters {
margin-bottom: 1rem;
}
.admin-filter-form {
display: flex;
flex-direction: column;
gap: 0.85rem;
}
.admin-filter-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.admin-filter-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--muted);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.admin-filter-field input,
.admin-filter-field select {
font: inherit;
text-transform: none;
letter-spacing: 0;
font-weight: 500;
color: var(--text);
background: #0d0d12;
border: 1px solid var(--card-border);
border-radius: 8px;
padding: 0.45rem 0.55rem;
}
.admin-filter-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.admin-filter-count {
font-size: 0.85rem;
}
.admin-table-strong {
font-weight: 600;
color: var(--text);
}
.admin-table-sub {
font-size: 0.78rem;
margin-top: 0.15rem;
}
.admin-session-head {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
}
.admin-session-head-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
}
.admin-session-match {
margin: 0.35rem 0 0;
font-size: 1.05rem;
}
.admin-session-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 1rem;
}
@media (max-width: 1100px) {
.admin-session-grid {
grid-template-columns: 1fr;
}
}
.admin-session-grid .panel h3,
.panel > h3 {
margin: 0 0 0.85rem;
font-size: 0.95rem;
}
.admin-kv {
margin: 0;
display: grid;
gap: 0.65rem;
}
.admin-kv > div {
display: grid;
grid-template-columns: minmax(110px, 34%) 1fr;
gap: 0.5rem 0.75rem;
align-items: start;
padding-bottom: 0.55rem;
border-bottom: 1px solid rgba(42, 42, 54, 0.7);
}
.admin-kv > div:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.admin-kv dt {
margin: 0;
color: var(--muted);
font-size: 0.78rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.admin-kv dd {
margin: 0;
font-size: 0.92rem;
word-break: break-word;
}
.admin-mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
}
.admin-mono--wrap {
white-space: pre-wrap;
word-break: break-all;
}
.admin-event-type {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.82rem;
font-weight: 600;
}
.admin-event-meta {
margin: 0;
display: grid;
gap: 0.25rem;
}
.admin-event-meta__row {
display: grid;
grid-template-columns: minmax(90px, 28%) 1fr;
gap: 0.35rem 0.6rem;
font-size: 0.82rem;
}
.admin-event-meta dt {
margin: 0;
color: var(--muted);
font-weight: 600;
}
.admin-event-meta dd {
margin: 0;
word-break: break-word;
}
.admin-event-meta code {
font-size: 0.78rem;
color: #d7d7e2;
}
.admin-table--events td {
vertical-align: top;
}
.admin-links-panel {
margin-bottom: 1rem;
}
.admin-scroll-bar {
position: relative;
height: 14px;
border-radius: 999px;
background: #0d0d12;
border: 1px solid var(--card-border);
overflow: hidden;
margin-bottom: 0.5rem;
}
.admin-scroll-bar__avg {
height: 100%;
background: linear-gradient(90deg, #1565c0, #43a047);
border-radius: 999px;
}
.admin-scroll-bar__max {
position: absolute;
top: -2px;
bottom: -2px;
width: 2px;
background: #e53935;
transform: translateX(-1px);
}
.admin-heatmap-stage {
position: relative;
overflow: auto;
width: 100%;
min-height: 70vh;
max-height: 85vh;
border: 1px solid var(--card-border);
border-radius: 10px;
background: #d7dbe2;
}
.admin-heatmap-scaler {
position: relative;
transform-origin: top left;
background: #fff;
box-shadow: 0 8px 28px rgba(0, 0, 0, 0.28);
}
.admin-heatmap-shot {
display: block;
width: 100%;
height: auto;
vertical-align: top;
pointer-events: none;
user-select: 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;
left: 0;
top: 0;
z-index: 1;
pointer-events: none;
}
+3
View File
@@ -70,6 +70,9 @@
function applyConsent(consent) {
if (consent && consent.analytics) {
loadGoogleAnalytics();
if (typeof window.mltvSiteAnalyticsStart === "function") {
window.mltvSiteAnalyticsStart();
}
}
}
+214 -6
View File
@@ -9,6 +9,9 @@ body {
a { color: #ff6b6b; text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 0 20px 48px; }
/* Form portale (società/squadra + copertina 16:9): usa tutta la larghezza desktop, non stile “mobile”. */
.wrap--portal { max-width: 1100px; }
.wrap--portal .card { max-width: none; }
.site-chrome {
position: sticky;
top: 0;
@@ -450,15 +453,19 @@ body.nav-menu-open { overflow: hidden; }
object-fit: contain;
}
.branding-cover-preview {
min-height: 140px;
min-height: 0;
aspect-ratio: 16 / 9;
width: 100%;
padding: 0;
overflow: hidden;
}
.branding-cover-preview__img {
max-width: 100%;
max-height: 180px;
width: auto;
height: auto;
max-width: none;
max-height: none;
width: 100%;
height: 100%;
border-radius: 8px;
object-fit: contain;
object-fit: cover;
}
.cover-remove-label {
display: flex;
@@ -468,6 +475,27 @@ body.nav-menu-open { overflow: hidden; }
font-size: 0.9rem;
color: #aaa;
}
.cover-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.cover-reset-btn {
padding: 10px 16px;
font-size: 0.9rem;
white-space: nowrap;
}
.cover-reset-btn:disabled {
opacity: 0.55;
cursor: default;
}
.cover-reset-hint {
flex: 1 1 100%;
margin: 0;
color: #a5f0b8;
}
.branding-file-input { margin-bottom: 0 !important; }
.branding-url-input { margin-bottom: 14px; }
.branding-color-row {
@@ -3257,3 +3285,183 @@ a.replay-archive__thumb:hover {
margin-top: 8px;
}
}
.site-announcements {
max-width: 1100px;
margin: 0 auto;
padding: 12px 20px 0;
display: grid;
gap: 10px;
}
.live-main .site-announcements {
max-width: none;
padding-left: 0;
padding-right: 0;
}
.site-announcement {
display: flex;
align-items: flex-start;
gap: 12px;
border-radius: 12px;
padding: 14px 16px;
border: 1px solid #3a3a48;
background: #16161e;
}
.site-announcement__body {
flex: 1;
min-width: 0;
}
.site-announcement__title {
margin: 0 0 4px;
font-weight: 700;
}
.site-announcement__text {
margin: 0;
color: #c8c8d4;
font-size: 0.95rem;
}
.site-announcement__text p {
margin: 0 0 0.4em;
}
.site-announcement__text p:last-child {
margin-bottom: 0;
}
.site-announcement__action {
margin: 8px 0 0;
}
.site-announcement__link {
font-weight: 700;
}
.site-announcement__dismiss {
flex-shrink: 0;
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: #9a9aad;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.site-announcement__dismiss:hover {
color: #fff;
background: #2a2a36;
}
.site-announcement--info {
border-color: #e53935;
background: rgba(229, 57, 53, 0.12);
}
.site-announcement--maintenance,
.site-announcement--warning {
border-color: #f5c518;
background: rgba(245, 197, 24, 0.12);
}
.site-announcement--update,
.site-announcement--success {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.12);
}
.site-announcement--critical {
border-color: #e53935;
background: rgba(229, 57, 53, 0.16);
}
.contacts-hero h1 {
max-width: 22ch;
}
.contacts-hero__meta {
margin: 0;
color: #888;
font-size: 0.92rem;
}
.contacts-channels {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
}
.contacts-channels .feature-card h2 {
margin: 0 0 8px;
font-size: 1.05rem;
}
.contacts-channels__email {
margin: 12px 0 0;
}
.contacts-channels__email a {
color: #e53935;
font-weight: 600;
word-break: break-all;
}
.contacts-company__details {
list-style: none;
margin: 0;
padding: 0;
text-align: center;
color: #ccc;
line-height: 1.7;
}
.contacts-form-panel {
max-width: 640px;
margin: 0 auto;
background: #14141c;
border: 1px solid #2a2a36;
border-radius: 14px;
padding: 28px 24px;
}
.contacts-form-panel h2 { margin: 0 0 10px; }
.contacts-form-panel__lead {
margin: 0 0 20px;
color: #aaa;
line-height: 1.55;
}
.contacts-form textarea {
width: 100%;
padding: 12px 14px;
margin-bottom: 12px;
border-radius: 8px;
border: 1px solid #3d3d4a;
background: #1c1c26;
color: #fff;
font-size: 1rem;
font-family: inherit;
resize: vertical;
min-height: 140px;
box-sizing: border-box;
}
.contacts-form textarea:focus {
outline: none;
border-color: #e53935;
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
}
.contacts-form .btn-primary {
width: 100%;
margin-top: 8px;
padding: 14px;
}
.contacts-hp {
position: absolute;
left: -10000px;
width: 1px;
height: 1px;
overflow: hidden;
}
@media (max-width: 999px) {
.contacts-channels {
grid-template-columns: 1fr;
}
}
+312
View File
@@ -0,0 +1,312 @@
/*! Match Live TV first-party site analytics (click + mouse move + scroll). Requires analytics cookie consent. */
(function () {
"use strict";
var ENDPOINT = "/analytics/events";
var SNAPSHOT_ENDPOINT = "/analytics/snapshot";
var STORAGE_KEY = "mltv_cookie_consent";
var COOKIE_NAME = "mltv_cookie_consent";
var FLUSH_MS = 6000;
var MAX_QUEUE = 60;
var MOVE_THROTTLE_MS = 180;
var MOVE_GRID = 40;
var SNAPSHOT_DELAY_MS = 4000;
var queue = [];
var moveBuckets = {};
var maxScroll = 0;
var flushTimer = null;
var started = false;
var lastMoveAt = 0;
function excludedPath(pathname) {
var p = pathname || "/";
if (p.indexOf("/admin") === 0) return true;
if (p.indexOf("/regia") === 0) return true;
if (p.indexOf("/analytics") === 0) return true;
if (/^\/live\/[^/]+/.test(p)) return true;
if (/^\/replay\/[^/]+/.test(p)) return true;
return false;
}
function hasAnalyticsConsent() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && parsed.analytics) return true;
}
} catch (e) { /* ignore */ }
var match = document.cookie.match(
new RegExp("(?:^|; )" + COOKIE_NAME.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "=([^;]*)")
);
return !!(match && decodeURIComponent(match[1]) === "all");
}
function deviceBucket() {
var w = window.innerWidth || document.documentElement.clientWidth || 1024;
if (w < 768) return "mobile";
if (w < 1024) return "tablet";
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() {
var doc = document.documentElement;
var body = document.body;
var scrollTop = window.pageYOffset || doc.scrollTop || body.scrollTop || 0;
var height = Math.max(
body.scrollHeight || 0,
doc.scrollHeight || 0,
body.offsetHeight || 0,
doc.offsetHeight || 0,
doc.clientHeight || 0
);
var view = window.innerHeight || doc.clientHeight || 0;
var pct = ((scrollTop + view) / Math.max(height, 1)) * 100;
if (scrollTop <= 0 && view >= height) return 100;
return Math.max(0, Math.min(100, Math.round(pct)));
}
function pushEvent(evt) {
if (!started) return;
queue.push(evt);
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) {
flushMovesIntoQueue();
if (!queue.length || !hasAnalyticsConsent()) {
queue = [];
return;
}
var batch = queue.splice(0, MAX_QUEUE);
var body = JSON.stringify({ events: batch });
try {
if (useBeacon && navigator.sendBeacon) {
var blob = new Blob([body], { type: "application/json" });
navigator.sendBeacon(ENDPOINT, blob);
return;
}
} catch (e) { /* fall through */ }
try {
fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: body,
keepalive: true,
credentials: "same-origin"
}).catch(function () { /* ignore */ });
} catch (err) { /* ignore */ }
}
function trackPageview() {
pushEvent({
type: "pageview",
path: location.pathname,
device: deviceBucket(),
ts: Date.now()
});
}
function onClick(event) {
var target = event.target;
if (!(target instanceof Element)) return;
if (target.closest("input, textarea, select, [contenteditable='true']")) return;
var size = pageSize();
var x = event.pageX;
var y = event.pageY;
if (typeof x !== "number" || typeof y !== "number") return;
pushEvent({
type: "click",
path: location.pathname,
device: deviceBucket(),
x: Math.max(0, Math.min(100, (x / size.width) * 100)),
y: Math.max(0, Math.min(100, (y / size.height) * 100)),
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() {
var pct = scrollPct();
if (pct > maxScroll) maxScroll = pct;
}
function flushScroll() {
if (maxScroll <= 0) return;
pushEvent({
type: "scroll",
path: location.pathname,
device: deviceBucket(),
scroll: maxScroll,
ts: Date.now()
});
}
function scheduleFlush() {
if (flushTimer) return;
flushTimer = setInterval(function () {
flushScroll();
flush(false);
}, FLUSH_MS);
}
function snapshotStorageKey() {
return "mltv_snap:" + location.pathname + ":" + deviceBucket();
}
function todayKey() {
var d = new Date();
return d.getUTCFullYear() + "-" + (d.getUTCMonth() + 1) + "-" + d.getUTCDate();
}
function alreadyCapturedToday() {
try {
return localStorage.getItem(snapshotStorageKey()) === todayKey();
} catch (e) {
return false;
}
}
function markCapturedToday() {
try {
localStorage.setItem(snapshotStorageKey(), todayKey());
} catch (e) { /* ignore */ }
}
function loadHtml2Canvas(cb) {
if (window.html2canvas) {
cb(window.html2canvas);
return;
}
var s = document.createElement("script");
s.src = "/vendor/html2canvas.min.js";
s.async = true;
s.onload = function () {
if (window.html2canvas) cb(window.html2canvas);
};
s.onerror = function () { /* ignore */ };
document.head.appendChild(s);
}
function captureSnapshot() {
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
loadHtml2Canvas(function (html2canvas) {
var size = pageSize();
html2canvas(document.documentElement, {
scale: Math.min(1, 1400 / Math.max(size.width, 1)),
useCORS: true,
allowTaint: false,
logging: false,
windowWidth: size.width,
windowHeight: size.height,
scrollX: 0,
scrollY: 0
})
.then(function (canvas) {
if (!canvas || !canvas.toBlob) return;
canvas.toBlob(
function (blob) {
if (!blob || blob.size < 8000 || blob.size > 1800000) return;
var fd = new FormData();
fd.append("image", blob, "snapshot.jpg");
fd.append("path", location.pathname);
fd.append("device", deviceBucket());
fd.append("width", String(Math.round(size.width)));
fd.append("height", String(Math.round(size.height)));
fetch(SNAPSHOT_ENDPOINT, {
method: "POST",
body: fd,
credentials: "same-origin"
})
.then(function (res) {
if (res.ok) markCapturedToday();
})
.catch(function () { /* ignore */ });
},
"image/jpeg",
0.72
);
})
.catch(function () { /* ignore */ });
});
}
function start() {
if (started) return;
if (excludedPath(location.pathname)) return;
if (!hasAnalyticsConsent()) return;
started = true;
maxScroll = scrollPct();
trackPageview();
document.addEventListener("click", onClick, true);
document.addEventListener("mousemove", onMouseMove, { passive: true });
window.addEventListener("scroll", onScroll, { passive: true });
document.addEventListener("visibilitychange", function () {
if (document.visibilityState === "hidden") {
flushScroll();
flush(true);
}
});
window.addEventListener("pagehide", function () {
flushScroll();
flush(true);
});
scheduleFlush();
setTimeout(captureSnapshot, SNAPSHOT_DELAY_MS);
}
window.mltvSiteAnalyticsStart = start;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
start();
}
})();
File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More