Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
70fccc493e | ||
|
|
406a90ae5b | ||
|
|
8e7bbdf8b2 | ||
|
|
b84321346f | ||
|
|
8a07c5d569 | ||
|
|
cc96c0396a | ||
|
|
79c48b226e | ||
|
|
c9b75065bc | ||
|
|
4ca9f3af8b | ||
|
|
f02782d0f0 | ||
|
|
fb82b74e92 | ||
|
|
60dfe7a75b | ||
|
|
a07f231417 |
@@ -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
|
||||||
@@ -11,7 +11,8 @@ module Admin
|
|||||||
kind: "info",
|
kind: "info",
|
||||||
severity: "info",
|
severity: "info",
|
||||||
status: "draft",
|
status: "draft",
|
||||||
show_on_app: true,
|
show_on_android: true,
|
||||||
|
show_on_ios: true,
|
||||||
show_on_web_public: true,
|
show_on_web_public: true,
|
||||||
show_on_web_private: true,
|
show_on_web_private: true,
|
||||||
dismissible: true
|
dismissible: true
|
||||||
@@ -55,9 +56,15 @@ module Admin
|
|||||||
params.require(:app_announcement).permit(
|
params.require(:app_announcement).permit(
|
||||||
:kind, :severity, :status, :title, :body,
|
:kind, :severity, :status, :title, :body,
|
||||||
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
|
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
|
||||||
:show_on_app, :show_on_web_public, :show_on_web_private
|
: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|
|
).tap do |permitted|
|
||||||
%i[dismissible show_on_app show_on_web_public show_on_web_private].each do |key|
|
%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])
|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
|
||||||
end
|
end
|
||||||
%i[starts_at ends_at action_url action_label].each do |key|
|
%i[starts_at ends_at action_url action_label].each do |key|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class AuthController < Admin::BaseController
|
class AuthController < Admin::BaseController
|
||||||
skip_before_action :require_admin_login, only: %i[new create]
|
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
|
def new
|
||||||
redirect_to admin_root_path if admin_logged_in?
|
redirect_to admin_root_path if admin_logged_in?
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ module Admin
|
|||||||
@host = HostMetrics.new.sample!
|
@host = HostMetrics.new.sample!
|
||||||
@active_sessions = StreamSession
|
@active_sessions = StreamSession
|
||||||
.where(status: DashboardStats::ACTIVE_STATUSES)
|
.where(status: DashboardStats::ACTIVE_STATUSES)
|
||||||
.includes(match: :team)
|
.includes(:stream_node, match: :team)
|
||||||
.order(started_at: :desc)
|
.order(started_at: :desc)
|
||||||
@teams = Team.includes(:matches).order(:name).limit(8)
|
@teams = Team.includes(:matches).order(:name).limit(8)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class SessionsController < Admin::BaseController
|
class SessionsController < Admin::BaseController
|
||||||
def index
|
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
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@session = StreamSession.find(params[:id])
|
@session = StreamSession
|
||||||
@events = @session.stream_events.recent.limit(50)
|
.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
|
end
|
||||||
|
|
||||||
def stop
|
def stop
|
||||||
@@ -39,5 +50,66 @@ module Admin
|
|||||||
regia_expires_at: @session.regia_token_expires_at&.iso8601
|
regia_expires_at: @session.regia_token_expires_at&.iso8601
|
||||||
}
|
}
|
||||||
end
|
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
|
||||||
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
|
||||||
@@ -5,7 +5,7 @@ module Api
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
platform = params[:platform].to_s.presence
|
platform = params[:platform].to_s.presence
|
||||||
announcements = AppAnnouncement.for_app.newest
|
announcements = AppAnnouncement.for_platform(platform).newest
|
||||||
render json: announcements.map { |item| item.as_api_json(platform: platform) }
|
render json: announcements.map { |item| item.as_api_json(platform: platform) }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
module Public
|
module Public
|
||||||
class SessionsController < WebBaseController
|
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
|
def new
|
||||||
if logged_in? && current_user.primary_club
|
if logged_in? && current_user.primary_club
|
||||||
redirect_to public_club_path(current_user.primary_club)
|
redirect_to public_club_path(current_user.primary_club)
|
||||||
|
|||||||
@@ -30,6 +30,88 @@ module AdminHelper
|
|||||||
links
|
links
|
||||||
end
|
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)
|
def admin_regia_expires_label(iso_time)
|
||||||
return if iso_time.blank?
|
return if iso_time.blank?
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -3,10 +3,23 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
SEVERITIES = %w[info warning critical].freeze
|
SEVERITIES = %w[info warning critical].freeze
|
||||||
STATUSES = %w[draft published archived].freeze
|
STATUSES = %w[draft published archived].freeze
|
||||||
CHANNELS = {
|
CHANNELS = {
|
||||||
app: :show_on_app,
|
android: :show_on_android,
|
||||||
|
ios: :show_on_ios,
|
||||||
web_public: :show_on_web_public,
|
web_public: :show_on_web_public,
|
||||||
web_private: :show_on_web_private
|
web_private: :show_on_web_private
|
||||||
}.freeze
|
}.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
|
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
|
||||||
|
|
||||||
@@ -14,12 +27,16 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
validates :kind, inclusion: { in: KINDS }
|
validates :kind, inclusion: { in: KINDS }
|
||||||
validates :severity, inclusion: { in: SEVERITIES }
|
validates :severity, inclusion: { in: SEVERITIES }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :title, length: { maximum: 120 }
|
validates :title, length: { maximum: 120 }, allow_blank: true
|
||||||
validates :body, length: { maximum: 2000 }
|
validates :body, length: { maximum: 2000 }, allow_blank: true
|
||||||
validates :action_label, length: { maximum: 40 }, allow_blank: true
|
validates :action_label, length: { maximum: 40 }, allow_blank: true
|
||||||
validates :action_url, format: { with: /\Ahttps?:\/\/.+/i, 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 :ends_at_after_starts_at
|
||||||
validate :at_least_one_channel
|
validate :at_least_one_channel
|
||||||
|
validate :translations_are_valid
|
||||||
|
before_validation :normalize_translations
|
||||||
|
|
||||||
scope :newest, -> { order(created_at: :desc) }
|
scope :newest, -> { order(created_at: :desc) }
|
||||||
scope :published, -> { where(status: "published") }
|
scope :published, -> { where(status: "published") }
|
||||||
@@ -38,7 +55,15 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def self.for_app
|
def self.for_app
|
||||||
for_channel(: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
|
end
|
||||||
|
|
||||||
def published?
|
def published?
|
||||||
@@ -51,23 +76,49 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def as_api_json(platform: nil)
|
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,
|
id: id,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
severity: severity,
|
severity: severity,
|
||||||
title: title,
|
title: copy_for("title", locale: locale),
|
||||||
body: body,
|
body: copy_for("body", locale: locale),
|
||||||
dismissible: dismissible,
|
dismissible: dismissible,
|
||||||
action_url: resolved_action_url(platform),
|
action_url: resolved_action_url(platform, locale: locale),
|
||||||
action_label: action_label.presence,
|
action_label: copy_for("action_label", locale: locale).presence,
|
||||||
starts_at: starts_at&.iso8601,
|
starts_at: starts_at&.iso8601,
|
||||||
ends_at: ends_at&.iso8601
|
ends_at: ends_at&.iso8601
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolved_action_url(platform)
|
def localized_title(locale: I18n.locale)
|
||||||
return action_url if action_url.present?
|
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"
|
return nil unless kind == "update"
|
||||||
|
|
||||||
case platform.to_s
|
case platform.to_s
|
||||||
@@ -78,8 +129,75 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
|
|
||||||
private
|
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
|
def at_least_one_channel
|
||||||
return if show_on_app? || show_on_web_public? || show_on_web_private?
|
return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private?
|
||||||
|
|
||||||
errors.add(:base, :no_channel)
|
errors.add(:base, :no_channel)
|
||||||
end
|
end
|
||||||
@@ -90,4 +208,33 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
|
|
||||||
errors.add(:ends_at, :invalid)
|
errors.add(:ends_at, :invalid)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ class StreamSession < ApplicationRecord
|
|||||||
belongs_to :stream_node, optional: true
|
belongs_to :stream_node, optional: true
|
||||||
has_many :stream_events, dependent: :destroy
|
has_many :stream_events, dependent: :destroy
|
||||||
has_one :score_state, dependent: :destroy
|
has_one :score_state, dependent: :destroy
|
||||||
|
has_one :recording
|
||||||
has_many :device_states, dependent: :destroy
|
has_many :device_states, dependent: :destroy
|
||||||
|
|
||||||
validates :platform, inclusion: { in: PLATFORMS }
|
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
|
||||||
@@ -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,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>
|
||||||
@@ -19,8 +19,12 @@
|
|||||||
<fieldset class="admin-channels">
|
<fieldset class="admin-channels">
|
||||||
<legend><%= t("admin.announcements.form.channels") %></legend>
|
<legend><%= t("admin.announcements.form.channels") %></legend>
|
||||||
<label class="admin-checkbox">
|
<label class="admin-checkbox">
|
||||||
<%= f.check_box :show_on_app %>
|
<%= f.check_box :show_on_android %>
|
||||||
<span><%= t("admin.announcements.channels.app") %></span>
|
<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>
|
||||||
<label class="admin-checkbox">
|
<label class="admin-checkbox">
|
||||||
<%= f.check_box :show_on_web_public %>
|
<%= f.check_box :show_on_web_public %>
|
||||||
@@ -33,15 +37,35 @@
|
|||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div>
|
<fieldset class="admin-channels">
|
||||||
<%= f.label :title, t("admin.announcements.form.title") %>
|
<legend><%= t("admin.announcements.form.locales_legend") %></legend>
|
||||||
<%= f.text_field :title, required: true, maxlength: 120 %>
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.locales_hint") %></p>
|
||||||
</div>
|
<div class="admin-locale-tabs" role="tablist">
|
||||||
|
<% language_options.each do |opt| %>
|
||||||
<div>
|
<button type="button"
|
||||||
<%= f.label :body, t("admin.announcements.form.body") %>
|
class="admin-locale-tab<%= " is-active" if opt[:code].to_s == I18n.default_locale.to_s %>"
|
||||||
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000 %>
|
data-locale-tab="<%= opt[:code] %>"
|
||||||
</div>
|
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 class="admin-form-row">
|
||||||
<div>
|
<div>
|
||||||
@@ -55,17 +79,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
|
|
||||||
<%= f.url_field :action_url, placeholder: "https://" %>
|
|
||||||
</div>
|
|
||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
|
|
||||||
<%= f.text_field :action_label, maxlength: 40 %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="admin-checkbox">
|
<label class="admin-checkbox">
|
||||||
<%= f.check_box :dismissible %>
|
<%= f.check_box :dismissible %>
|
||||||
<span><%= t("admin.announcements.form.dismissible") %></span>
|
<span><%= t("admin.announcements.form.dismissible") %></span>
|
||||||
@@ -77,3 +90,23 @@
|
|||||||
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% 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>
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong><%= item.title %></strong>
|
<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.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>
|
||||||
<td class="muted"><%= announcement_channels_label(item) %></td>
|
<td class="muted"><%= announcement_channels_label(item) %></td>
|
||||||
<td class="muted"><%= announcement_window_label(item) %></td>
|
<td class="muted"><%= announcement_window_label(item) %></td>
|
||||||
|
|||||||
@@ -107,6 +107,7 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th><%= t("admin.dashboard.sessions.table.match") %></th>
|
<th><%= t("admin.dashboard.sessions.table.match") %></th>
|
||||||
<th><%= t("admin.dashboard.sessions.table.status") %></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.start") %></th>
|
||||||
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -117,12 +118,15 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
<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><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="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
|
||||||
<td class="admin-link-compact">
|
<td>
|
||||||
<% admin_session_watch_links(s).each do |link| %>
|
<div class="admin-link-compact">
|
||||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
<% admin_session_watch_links(s).each do |link| %>
|
||||||
<% end %>
|
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||||
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
<% end %>
|
||||||
|
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-actions">
|
<td class="admin-actions">
|
||||||
<%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %>
|
<%= 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 %>
|
||||||
@@ -1,28 +1,131 @@
|
|||||||
<h2><%= t("admin.sessions.index.title") %></h2>
|
<% content_for :body_class, "admin-body" %>
|
||||||
<table class="admin-table">
|
|
||||||
<thead>
|
<div class="admin-page-head">
|
||||||
<tr>
|
<h2 class="admin-page-title"><%= t("admin.sessions.index.title") %></h2>
|
||||||
<th><%= t("admin.sessions.index.table.match") %></th>
|
<p class="muted admin-page-sub"><%= t("admin.sessions.index.lead") %></p>
|
||||||
<th><%= t("admin.sessions.index.table.status") %></th>
|
</div>
|
||||||
<th><%= t("admin.sessions.index.table.disconnects") %></th>
|
|
||||||
<th><%= t("admin.sessions.index.table.link") %></th>
|
<div class="panel admin-sessions-filters">
|
||||||
<th></th>
|
<%= form_with url: admin_sessions_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
</tr>
|
<div class="admin-filter-grid">
|
||||||
</thead>
|
<label class="admin-filter-field">
|
||||||
<tbody>
|
<span><%= t("admin.sessions.index.filters.q") %></span>
|
||||||
<% @sessions.each do |s| %>
|
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.sessions.index.filters.q_placeholder") %>
|
||||||
<tr>
|
</label>
|
||||||
<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>
|
<label class="admin-filter-field">
|
||||||
<td><%= s.disconnection_count %></td>
|
<span><%= t("admin.sessions.index.filters.status") %></span>
|
||||||
<td class="admin-link-compact">
|
<%= select_tag :status,
|
||||||
<% admin_session_watch_links(s).each do |link| %>
|
options_for_select(
|
||||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
[[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 %>
|
<% end %>
|
||||||
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
|
</div>
|
||||||
</tr>
|
<% else %>
|
||||||
<% end %>
|
<p class="empty"><%= t("admin.sessions.index.none") %></p>
|
||||||
</tbody>
|
<% end %>
|
||||||
</table>
|
</div>
|
||||||
|
|||||||
@@ -1,39 +1,247 @@
|
|||||||
<h2><%= t("admin.sessions.show.title", id: @session.id) %></h2>
|
<% content_for :body_class, "admin-body" %>
|
||||||
<p><%= t("admin.sessions.show.status_label") %> <strong><%= @session.status %></strong></p>
|
<% team = @session.match.team %>
|
||||||
<% unless @session.terminal? %>
|
<% club = @club || team.club %>
|
||||||
<p>
|
|
||||||
<%= button_to t("admin.sessions.show.stop_button"),
|
<div class="admin-page-head admin-session-head">
|
||||||
stop_admin_session_path(@session),
|
<div>
|
||||||
method: :post,
|
<p class="muted admin-page-sub">
|
||||||
class: "admin-btn admin-btn--danger",
|
<%= link_to t("admin.sessions.show.back"), admin_sessions_path %>
|
||||||
form: { data: { turbo_confirm: t("admin.sessions.show.stop_confirm") } } %>
|
</p>
|
||||||
</p>
|
<h2 class="admin-page-title"><%= t("admin.sessions.show.heading") %></h2>
|
||||||
<% end %>
|
<p class="admin-session-match">
|
||||||
<p><%= t("admin.sessions.show.match_label", team: @session.match.team.name, opponent: @session.match.opponent_name) %></p>
|
<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 %>
|
<%= render "admin/sessions/links", session: @session %>
|
||||||
|
|
||||||
<% if @session.youtube_broadcast_id %>
|
<% if @session.device_states.any? %>
|
||||||
<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>
|
<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 %>
|
<% end %>
|
||||||
<p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p>
|
|
||||||
|
|
||||||
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
<section class="panel">
|
||||||
<table>
|
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
||||||
<thead>
|
<% if @events.any? %>
|
||||||
<tr>
|
<div class="admin-table-wrap">
|
||||||
<th><%= t("admin.sessions.show.table.type") %></th>
|
<table class="admin-table admin-table--events">
|
||||||
<th><%= t("admin.sessions.show.table.when") %></th>
|
<thead>
|
||||||
<th><%= t("admin.sessions.show.table.meta") %></th>
|
<tr>
|
||||||
</tr>
|
<th><%= t("admin.sessions.show.table.type") %></th>
|
||||||
</thead>
|
<th><%= t("admin.sessions.show.table.when") %></th>
|
||||||
<tbody>
|
<th><%= t("admin.sessions.show.table.meta") %></th>
|
||||||
<% @events.each do |e| %>
|
</tr>
|
||||||
<tr>
|
</thead>
|
||||||
<td><%= e.event_type %></td>
|
<tbody>
|
||||||
<td><%= e.occurred_at %></td>
|
<% @events.each do |e| %>
|
||||||
<td><%= e.metadata.to_json %></td>
|
<tr>
|
||||||
</tr>
|
<td><span class="admin-event-type"><%= e.event_type %></span></td>
|
||||||
<% end %>
|
<td class="muted"><%= admin_datetime(e.occurred_at, with_seconds: true) %></td>
|
||||||
</tbody>
|
<td><%= admin_format_event_meta(e.metadata) %></td>
|
||||||
</table>
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.sessions.show.events_none") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
<title><%= t("admin.layout.title") %></title>
|
<title><%= t("admin.layout.title") %></title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<link rel="stylesheet" href="/admin.css?v=6">
|
<%= csrf_meta_tags %>
|
||||||
|
<link rel="stylesheet" href="/admin.css?v=15">
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
<%= 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.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.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.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.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||||
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<meta name="application-name" content="Match Live TV">
|
<meta name="application-name" content="Match Live TV">
|
||||||
|
<%= csrf_meta_tags %>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<%= yield :head %>
|
<%= 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="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">
|
||||||
@@ -25,6 +26,7 @@
|
|||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
|
<%= csrf_meta_tags %>
|
||||||
<%= render "shared/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="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=68">
|
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
<%= render "shared/marketing_footer" %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -101,6 +101,12 @@
|
|||||||
<td><%= t("legal.cookies.s4_2_row3_duration") %></td>
|
<td><%= t("legal.cookies.s4_2_row3_duration") %></td>
|
||||||
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
<td><%= t("legal.cookies.s4_2_row3_provider") %></td>
|
||||||
</tr>
|
</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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
||||||
data-announcement-id="<%= item.id %>">
|
data-announcement-id="<%= item.id %>">
|
||||||
<div class="site-announcement__body">
|
<div class="site-announcement__body">
|
||||||
<p class="site-announcement__title"><%= item.title %></p>
|
<p class="site-announcement__title"><%= item.localized_title %></p>
|
||||||
<div class="site-announcement__text"><%= simple_format(item.body) %></div>
|
<div class="site-announcement__text"><%= simple_format(item.localized_body) %></div>
|
||||||
<% if item.action_url.present? %>
|
<% action_url = item.resolved_action_url(nil) %>
|
||||||
|
<% if action_url.present? %>
|
||||||
<p class="site-announcement__action">
|
<p class="site-announcement__action">
|
||||||
<%= link_to (item.action_label.presence || t("announcement.open")), item.action_url,
|
<%= link_to (item.localized_action_label.presence || t("announcement.open")), action_url,
|
||||||
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
|
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ de:
|
|||||||
billing: Abrechnung
|
billing: Abrechnung
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
|
analytics: Analytics
|
||||||
stream_nodes: Stream-Knoten
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
@@ -110,6 +111,7 @@ de:
|
|||||||
table:
|
table:
|
||||||
match: Spiel
|
match: Spiel
|
||||||
status: Status
|
status: Status
|
||||||
|
ingest: Ingest
|
||||||
start: Start
|
start: Start
|
||||||
link: Link
|
link: Link
|
||||||
none: Derzeit keine aktive Sitzung.
|
none: Derzeit keine aktive Sitzung.
|
||||||
@@ -250,27 +252,88 @@ de:
|
|||||||
sessions:
|
sessions:
|
||||||
index:
|
index:
|
||||||
title: Stream-Sitzungen
|
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:
|
table:
|
||||||
|
club: Verein
|
||||||
match: Spiel
|
match: Spiel
|
||||||
status: Status
|
status: Status
|
||||||
|
started: Start
|
||||||
|
ended: Ende
|
||||||
|
duration: Dauer
|
||||||
|
ingest: Ingest
|
||||||
disconnects: Verbindungsabbrüche
|
disconnects: Verbindungsabbrüche
|
||||||
link: Link
|
link: Link
|
||||||
detail: Details
|
detail: Details
|
||||||
regia: Regie
|
regia: Regie
|
||||||
show:
|
show:
|
||||||
title: "Sitzung %{id}"
|
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:"
|
status_label: "Status:"
|
||||||
stop_button: Sitzung beenden
|
stop_button: Sitzung beenden
|
||||||
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
|
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
|
||||||
match_label: "Spiel: %{team} gegen %{opponent}"
|
match_label: "Spiel: %{team} gegen %{opponent}"
|
||||||
youtube_studio: YouTube Studio
|
youtube_studio: YouTube Studio
|
||||||
broadcast: Übertragung
|
broadcast: Übertragung
|
||||||
|
open_replays: Vereins-Replays öffnen
|
||||||
|
audio_muted: Audio stumm
|
||||||
|
audio_on: Audio an
|
||||||
|
ingest_node: "Ingest-Knoten:"
|
||||||
rtmp_ingest: "RTMP-Ingest:"
|
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:
|
table:
|
||||||
type: Typ
|
type: Typ
|
||||||
when: Wann
|
when: Wann
|
||||||
meta: Meta
|
meta: Details
|
||||||
links:
|
links:
|
||||||
watch_title: Live-Link
|
watch_title: Live-Link
|
||||||
none: Kein Video-Link für diese Sitzung verfügbar.
|
none: Kein Video-Link für diese Sitzung verfügbar.
|
||||||
@@ -281,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.
|
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.
|
none_active: Kein aktiver Regie-Link. Erzeuge einen Link zum Teilen mit der Person, die den Spielstand verwaltet.
|
||||||
generate_button: Regie-Link erzeugen
|
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:
|
billing:
|
||||||
index:
|
index:
|
||||||
title: Zu berechnende Zahlungen
|
title: Zu berechnende Zahlungen
|
||||||
@@ -398,7 +509,8 @@ de:
|
|||||||
published: Veröffentlicht
|
published: Veröffentlicht
|
||||||
archived: Archiviert
|
archived: Archiviert
|
||||||
channels:
|
channels:
|
||||||
app: Apps (Android und iOS)
|
android: Android-App
|
||||||
|
ios: iOS-App
|
||||||
web_public: Öffentliche Website
|
web_public: Öffentliche Website
|
||||||
web_private: Login-Bereich
|
web_private: Login-Bereich
|
||||||
window:
|
window:
|
||||||
@@ -410,15 +522,18 @@ de:
|
|||||||
severity: Schwere
|
severity: Schwere
|
||||||
status: Status
|
status: Status
|
||||||
channels: Wo anzeigen
|
channels: Wo anzeigen
|
||||||
channels_hint: Eine oder mehrere Ziele wählen. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto).
|
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
|
title: Titel
|
||||||
body: Text
|
body: Text
|
||||||
starts_at: Sichtbar ab
|
starts_at: Sichtbar ab
|
||||||
ends_at: Sichtbar bis
|
ends_at: Sichtbar bis
|
||||||
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
|
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
|
||||||
action_url: Link (optional)
|
action_url: Link (optional)
|
||||||
action_url_hint: Bei Updates öffnet eine leere URL den Store. Sonst eine https-URL.
|
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
|
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: Nutzer können den Hinweis schließen
|
||||||
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
|
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
|
||||||
submit_create: Hinweis speichern
|
submit_create: Hinweis speichern
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ en:
|
|||||||
billing: Billing
|
billing: Billing
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
analytics: Analytics
|
||||||
stream_nodes: Stream nodes
|
stream_nodes: Stream nodes
|
||||||
password: Password
|
password: Password
|
||||||
logout: Log out
|
logout: Log out
|
||||||
@@ -110,6 +111,7 @@ en:
|
|||||||
table:
|
table:
|
||||||
match: Match
|
match: Match
|
||||||
status: Status
|
status: Status
|
||||||
|
ingest: Ingest
|
||||||
start: Start
|
start: Start
|
||||||
link: Link
|
link: Link
|
||||||
none: No active session right now.
|
none: No active session right now.
|
||||||
@@ -250,27 +252,88 @@ en:
|
|||||||
sessions:
|
sessions:
|
||||||
index:
|
index:
|
||||||
title: Stream sessions
|
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:
|
table:
|
||||||
|
club: Club
|
||||||
match: Match
|
match: Match
|
||||||
status: Status
|
status: Status
|
||||||
|
started: Started
|
||||||
|
ended: Ended
|
||||||
|
duration: Duration
|
||||||
|
ingest: Ingest
|
||||||
disconnects: Disconnects
|
disconnects: Disconnects
|
||||||
link: Link
|
link: Link
|
||||||
detail: Details
|
detail: Details
|
||||||
regia: Control
|
regia: Control
|
||||||
show:
|
show:
|
||||||
title: "Session %{id}"
|
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:"
|
status_label: "Status:"
|
||||||
stop_button: End session
|
stop_button: End session
|
||||||
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
|
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
|
||||||
match_label: "Match: %{team} vs %{opponent}"
|
match_label: "Match: %{team} vs %{opponent}"
|
||||||
youtube_studio: YouTube Studio
|
youtube_studio: YouTube Studio
|
||||||
broadcast: Broadcast
|
broadcast: Broadcast
|
||||||
|
open_replays: Open club replays
|
||||||
|
audio_muted: Audio muted
|
||||||
|
audio_on: Audio on
|
||||||
|
ingest_node: "Ingest node:"
|
||||||
rtmp_ingest: "RTMP ingest:"
|
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:
|
table:
|
||||||
type: Type
|
type: Type
|
||||||
when: When
|
when: When
|
||||||
meta: Meta
|
meta: Details
|
||||||
links:
|
links:
|
||||||
watch_title: Live link
|
watch_title: Live link
|
||||||
none: No video link available for this session.
|
none: No video link available for this session.
|
||||||
@@ -281,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.
|
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.
|
none_active: No active control link. Generate a link to share with whoever manages the score.
|
||||||
generate_button: Generate control link
|
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:
|
billing:
|
||||||
index:
|
index:
|
||||||
title: Payments to invoice
|
title: Payments to invoice
|
||||||
@@ -398,7 +509,8 @@ en:
|
|||||||
published: Published
|
published: Published
|
||||||
archived: Archived
|
archived: Archived
|
||||||
channels:
|
channels:
|
||||||
app: Apps (Android and iOS)
|
android: Android app
|
||||||
|
ios: iOS app
|
||||||
web_public: Public website
|
web_public: Public website
|
||||||
web_private: Logged-in area
|
web_private: Logged-in area
|
||||||
window:
|
window:
|
||||||
@@ -410,15 +522,18 @@ en:
|
|||||||
severity: Severity
|
severity: Severity
|
||||||
status: Status
|
status: Status
|
||||||
channels: Where to show it
|
channels: Where to show it
|
||||||
channels_hint: Select one or more destinations. The logged-in area is visible after sign-in (club, teams, account).
|
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
|
title: Title
|
||||||
body: Body
|
body: Body
|
||||||
starts_at: Visible from
|
starts_at: Visible from
|
||||||
ends_at: Visible until
|
ends_at: Visible until
|
||||||
window_hint: Leave empty to show immediately while published.
|
window_hint: Leave empty to show immediately while published.
|
||||||
action_url: Link (optional)
|
action_url: Link (optional)
|
||||||
action_url_hint: For updates, an empty URL opens the store. Otherwise use an https URL.
|
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
|
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: Users can dismiss the notice
|
||||||
dismissible_hint: Uncheck for maintenance notices that should stay visible.
|
dismissible_hint: Uncheck for maintenance notices that should stay visible.
|
||||||
submit_create: Save notice
|
submit_create: Save notice
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ es:
|
|||||||
billing: Facturación
|
billing: Facturación
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
|
analytics: Analytics
|
||||||
stream_nodes: Nodos stream
|
stream_nodes: Nodos stream
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
logout: Salir
|
logout: Salir
|
||||||
@@ -110,6 +111,7 @@ es:
|
|||||||
table:
|
table:
|
||||||
match: Partido
|
match: Partido
|
||||||
status: Estado
|
status: Estado
|
||||||
|
ingest: Ingest
|
||||||
start: Inicio
|
start: Inicio
|
||||||
link: Enlace
|
link: Enlace
|
||||||
none: No hay ninguna sesión activa en este momento.
|
none: No hay ninguna sesión activa en este momento.
|
||||||
@@ -250,27 +252,88 @@ es:
|
|||||||
sessions:
|
sessions:
|
||||||
index:
|
index:
|
||||||
title: Sesiones de emisión
|
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:
|
table:
|
||||||
|
club: Club
|
||||||
match: Partido
|
match: Partido
|
||||||
status: Estado
|
status: Estado
|
||||||
|
started: Inicio
|
||||||
|
ended: Fin
|
||||||
|
duration: Duración
|
||||||
|
ingest: Ingest
|
||||||
disconnects: Desconexiones
|
disconnects: Desconexiones
|
||||||
link: Enlace
|
link: Enlace
|
||||||
detail: Detalle
|
detail: Detalle
|
||||||
regia: Regie
|
regia: Regie
|
||||||
show:
|
show:
|
||||||
title: "Sesión %{id}"
|
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:"
|
status_label: "Estado:"
|
||||||
stop_button: Finalizar sesión
|
stop_button: Finalizar sesión
|
||||||
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
|
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
|
||||||
match_label: "Partido: %{team} vs %{opponent}"
|
match_label: "Partido: %{team} vs %{opponent}"
|
||||||
youtube_studio: YouTube Studio
|
youtube_studio: YouTube Studio
|
||||||
broadcast: Emisión
|
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:"
|
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:
|
table:
|
||||||
type: Tipo
|
type: Tipo
|
||||||
when: Cuándo
|
when: Cuándo
|
||||||
meta: Meta
|
meta: Detalles
|
||||||
links:
|
links:
|
||||||
watch_title: Enlace en directo
|
watch_title: Enlace en directo
|
||||||
none: No hay ningún enlace de vídeo disponible para esta sesión.
|
none: No hay ningún enlace de vídeo disponible para esta sesión.
|
||||||
@@ -281,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.
|
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.
|
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
|
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:
|
billing:
|
||||||
index:
|
index:
|
||||||
title: Pagos por facturar
|
title: Pagos por facturar
|
||||||
@@ -398,7 +509,8 @@ es:
|
|||||||
published: Publicado
|
published: Publicado
|
||||||
archived: Archivado
|
archived: Archivado
|
||||||
channels:
|
channels:
|
||||||
app: Apps (Android e iOS)
|
android: App Android
|
||||||
|
ios: App iOS
|
||||||
web_public: Sitio público
|
web_public: Sitio público
|
||||||
web_private: Área privada
|
web_private: Área privada
|
||||||
window:
|
window:
|
||||||
@@ -410,15 +522,18 @@ es:
|
|||||||
severity: Gravedad
|
severity: Gravedad
|
||||||
status: Estado
|
status: Estado
|
||||||
channels: Dónde mostrarlo
|
channels: Dónde mostrarlo
|
||||||
channels_hint: Elige uno o más destinos. El área privada se ve tras iniciar sesión (club, equipos, cuenta).
|
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
|
title: Título
|
||||||
body: Texto
|
body: Texto
|
||||||
starts_at: Visible desde
|
starts_at: Visible desde
|
||||||
ends_at: Visible hasta
|
ends_at: Visible hasta
|
||||||
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
|
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
|
||||||
action_url: Enlace (opcional)
|
action_url: Enlace (opcional)
|
||||||
action_url_hint: En actualizaciones, una URL vacía abre la tienda. Si no, usa una URL https.
|
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
|
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: El usuario puede cerrar el aviso
|
||||||
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
|
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
|
||||||
submit_create: Guardar aviso
|
submit_create: Guardar aviso
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ fr:
|
|||||||
billing: Facturation
|
billing: Facturation
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
analytics: Analytics
|
||||||
stream_nodes: Nœuds stream
|
stream_nodes: Nœuds stream
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
@@ -110,6 +111,7 @@ fr:
|
|||||||
table:
|
table:
|
||||||
match: Match
|
match: Match
|
||||||
status: Statut
|
status: Statut
|
||||||
|
ingest: Ingest
|
||||||
start: Début
|
start: Début
|
||||||
link: Lien
|
link: Lien
|
||||||
none: Aucune session active pour le moment.
|
none: Aucune session active pour le moment.
|
||||||
@@ -250,27 +252,88 @@ fr:
|
|||||||
sessions:
|
sessions:
|
||||||
index:
|
index:
|
||||||
title: Sessions de diffusion
|
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:
|
table:
|
||||||
|
club: Club
|
||||||
match: Match
|
match: Match
|
||||||
status: Statut
|
status: Statut
|
||||||
|
started: Début
|
||||||
|
ended: Fin
|
||||||
|
duration: Durée
|
||||||
|
ingest: Ingest
|
||||||
disconnects: Déconnexions
|
disconnects: Déconnexions
|
||||||
link: Lien
|
link: Lien
|
||||||
detail: Détail
|
detail: Détail
|
||||||
regia: Régie
|
regia: Régie
|
||||||
show:
|
show:
|
||||||
title: "Session %{id}"
|
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 :"
|
status_label: "Statut :"
|
||||||
stop_button: Terminer la session
|
stop_button: Terminer la session
|
||||||
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
|
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
|
||||||
match_label: "Match : %{team} contre %{opponent}"
|
match_label: "Match : %{team} contre %{opponent}"
|
||||||
youtube_studio: YouTube Studio
|
youtube_studio: YouTube Studio
|
||||||
broadcast: Diffusion
|
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 :"
|
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:
|
table:
|
||||||
type: Type
|
type: Type
|
||||||
when: Quand
|
when: Quand
|
||||||
meta: Méta
|
meta: Détails
|
||||||
links:
|
links:
|
||||||
watch_title: Lien en direct
|
watch_title: Lien en direct
|
||||||
none: Aucun lien vidéo disponible pour cette session.
|
none: Aucun lien vidéo disponible pour cette session.
|
||||||
@@ -281,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.
|
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.
|
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
|
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:
|
billing:
|
||||||
index:
|
index:
|
||||||
title: Paiements à facturer
|
title: Paiements à facturer
|
||||||
@@ -398,7 +509,8 @@ fr:
|
|||||||
published: Publié
|
published: Publié
|
||||||
archived: Archivé
|
archived: Archivé
|
||||||
channels:
|
channels:
|
||||||
app: Apps (Android et iOS)
|
android: App Android
|
||||||
|
ios: App iOS
|
||||||
web_public: Site public
|
web_public: Site public
|
||||||
web_private: Espace connecté
|
web_private: Espace connecté
|
||||||
window:
|
window:
|
||||||
@@ -410,15 +522,18 @@ fr:
|
|||||||
severity: Gravité
|
severity: Gravité
|
||||||
status: Statut
|
status: Statut
|
||||||
channels: Où l'afficher
|
channels: Où l'afficher
|
||||||
channels_hint: Choisissez une ou plusieurs destinations. L'espace connecté est visible après connexion (club, équipes, compte).
|
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
|
title: Titre
|
||||||
body: Texte
|
body: Texte
|
||||||
starts_at: Visible à partir de
|
starts_at: Visible à partir de
|
||||||
ends_at: Visible jusqu'à
|
ends_at: Visible jusqu'à
|
||||||
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
|
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
|
||||||
action_url: Lien (facultatif)
|
action_url: Lien (facultatif)
|
||||||
action_url_hint: Pour une mise à jour, une URL vide ouvre le store. Sinon une URL https.
|
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
|
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: L'utilisateur peut fermer l'alerte
|
||||||
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
|
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
|
||||||
submit_create: Enregistrer l'alerte
|
submit_create: Enregistrer l'alerte
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ it:
|
|||||||
billing: Fatturazione
|
billing: Fatturazione
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
|
analytics: Analytics
|
||||||
stream_nodes: Nodi stream
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
@@ -114,6 +115,7 @@ it:
|
|||||||
table:
|
table:
|
||||||
match: Partita
|
match: Partita
|
||||||
status: Stato
|
status: Stato
|
||||||
|
ingest: Ingest
|
||||||
start: Inizio
|
start: Inizio
|
||||||
link: Link
|
link: Link
|
||||||
none: Nessuna sessione attiva in questo momento.
|
none: Nessuna sessione attiva in questo momento.
|
||||||
@@ -271,27 +273,88 @@ it:
|
|||||||
sessions:
|
sessions:
|
||||||
index:
|
index:
|
||||||
title: Sessioni di streaming
|
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:
|
table:
|
||||||
|
club: Società
|
||||||
match: Match
|
match: Match
|
||||||
status: Stato
|
status: Stato
|
||||||
|
started: Inizio
|
||||||
|
ended: Fine
|
||||||
|
duration: Durata
|
||||||
|
ingest: Ingest
|
||||||
disconnects: Disconnessioni
|
disconnects: Disconnessioni
|
||||||
link: Link
|
link: Link
|
||||||
detail: Dettaglio
|
detail: Dettaglio
|
||||||
regia: Regia
|
regia: Regia
|
||||||
show:
|
show:
|
||||||
title: "Sessione %{id}"
|
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:"
|
status_label: "Stato:"
|
||||||
stop_button: Termina sessione
|
stop_button: Termina sessione
|
||||||
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
|
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
|
||||||
match_label: "Match: %{team} vs %{opponent}"
|
match_label: "Match: %{team} vs %{opponent}"
|
||||||
youtube_studio: YouTube Studio
|
youtube_studio: YouTube Studio
|
||||||
broadcast: Broadcast
|
broadcast: Broadcast
|
||||||
|
open_replays: Apri replay società
|
||||||
|
audio_muted: Audio muto
|
||||||
|
audio_on: Audio attivo
|
||||||
|
ingest_node: "Nodo ingest:"
|
||||||
rtmp_ingest: "RTMP 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:
|
table:
|
||||||
type: Tipo
|
type: Tipo
|
||||||
when: Quando
|
when: Quando
|
||||||
meta: Meta
|
meta: Dettagli
|
||||||
links:
|
links:
|
||||||
watch_title: Link diretta
|
watch_title: Link diretta
|
||||||
none: Nessun link video disponibile per questa sessione.
|
none: Nessun link video disponibile per questa sessione.
|
||||||
@@ -302,6 +365,54 @@ it:
|
|||||||
active_exists: Esiste già un link regia attivo (l’URL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.
|
active_exists: Esiste già un link regia attivo (l’URL 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.
|
none_active: Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.
|
||||||
generate_button: Genera link regia
|
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:
|
billing:
|
||||||
index:
|
index:
|
||||||
title: Pagamenti da fatturare
|
title: Pagamenti da fatturare
|
||||||
@@ -436,7 +547,8 @@ it:
|
|||||||
published: Pubblicato
|
published: Pubblicato
|
||||||
archived: Archiviato
|
archived: Archiviato
|
||||||
channels:
|
channels:
|
||||||
app: App (Android e iOS)
|
android: App Android
|
||||||
|
ios: App iOS
|
||||||
web_public: Sito pubblico
|
web_public: Sito pubblico
|
||||||
web_private: Area riservata
|
web_private: Area riservata
|
||||||
window:
|
window:
|
||||||
@@ -448,15 +560,18 @@ it:
|
|||||||
severity: Gravità
|
severity: Gravità
|
||||||
status: Stato
|
status: Stato
|
||||||
channels: Dove mostrarlo
|
channels: Dove mostrarlo
|
||||||
channels_hint: Puoi selezionare una o più destinazioni. L'area riservata è visibile dopo il login (società, squadre, account).
|
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
|
title: Titolo
|
||||||
body: Testo
|
body: Testo
|
||||||
starts_at: Inizio visibilità
|
starts_at: Inizio visibilità
|
||||||
ends_at: Fine visibilità
|
ends_at: Fine visibilità
|
||||||
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
|
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
|
||||||
action_url: Link (facoltativo)
|
action_url: Link (facoltativo)
|
||||||
action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store. Altrimenti un URL https.
|
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
|
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: L'utente può chiudere l'avviso
|
||||||
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
|
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
|
||||||
submit_create: Salva avviso
|
submit_create: Salva avviso
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ de:
|
|||||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
|
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:
|
club:
|
||||||
back_to_club: "← Verein"
|
back_to_club: "← Verein"
|
||||||
sport_label: Hauptsportart
|
sport_label: Hauptsportart
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ en:
|
|||||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Select at least one destination (apps, public website or logged-in area).
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Main sport
|
sport_label: Main sport
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ es:
|
|||||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Elige al menos un destino (apps, sitio público o área privada).
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Deporte principal
|
sport_label: Deporte principal
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ fr:
|
|||||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Sport principal
|
sport_label: Sport principal
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ it:
|
|||||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
|
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:
|
club:
|
||||||
back_to_club: "← Società"
|
back_to_club: "← Società"
|
||||||
sport_label: Sport principale
|
sport_label: Sport principale
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ de:
|
|||||||
s10_title: 10. Sicherheit
|
s10_title: 10. Sicherheit
|
||||||
s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns."
|
s10_body: "Wir setzen angemessene technische und organisatorische Maßnahmen ein (authentifizierter Zugang, verschlüsselte Passwörter, HTTPS-Kommunikation, Trennung der Umgebungen, eingeschränkter Personalzugang). Kein System ist unverwundbar: Wenn Sie einen unbefugten Zugriff vermuten, ändern Sie Ihr Passwort und kontaktieren Sie uns."
|
||||||
s11_title: 11. Cookies und ähnliche Technologien
|
s11_title: 11. Cookies und ähnliche Technologien
|
||||||
s11_p1_html: "Die Website verwendet notwendige technische Cookies (Login-Sitzung, Sicherheit, Speicherung der Cookie-Präferenzen) und, vorbehaltlich der Einwilligung über das Banner, <strong>Google Analytics</strong> 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_p2_html: "Sie können Ihre Präferenzen jederzeit über den Link „Cookies verwalten“ in der Fußzeile ändern oder die %{cookie_policy_link} einsehen."
|
||||||
s11_cookie_policy_link_text: Cookie-Richtlinie
|
s11_cookie_policy_link_text: Cookie-Richtlinie
|
||||||
s12_title: 12. Änderungen
|
s12_title: 12. Änderungen
|
||||||
@@ -169,7 +169,7 @@ de:
|
|||||||
s4_1_row2_duration: 12 Monate
|
s4_1_row2_duration: 12 Monate
|
||||||
s4_1_row2_provider: Match Live TV (First-Party)
|
s4_1_row2_provider: Match Live TV (First-Party)
|
||||||
s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung)
|
s4_2_title: 4.2 Analytics (nur mit Ihrer Einwilligung)
|
||||||
s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir <strong>Google Analytics 4</strong>, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet."
|
s4_2_p1_html: "Wenn Sie „Alle Cookies“ akzeptieren oder die Statistiken im Banner aktivieren, laden wir <strong>Google Analytics 4</strong>, um zu verstehen, wie die Website genutzt wird (besuchte Seiten, aggregierte Herkunft, Gerät). Die Daten werden von Google Ireland Limited / Google LLC gemäß deren Richtlinien verarbeitet. Mit derselben Einwilligung erfassen wir außerdem <strong>aggregierte First-Party-Statistiken</strong> (Mausbewegungen, Klicks und Scrolltiefe, ohne Nutzeridentifikation) für interne Heatmaps zur Produktverbesserung und Plattform-Marketing. Wir tracken weder Admin-Bereich, Regie, Live-Player noch Replay-Seiten."
|
||||||
s4_2_active_html: "Aktive Mess-ID auf der Website: <code>%{id}</code>"
|
s4_2_active_html: "Aktive Mess-ID auf der Website: <code>%{id}</code>"
|
||||||
s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet.
|
s4_2_inactive: Google Analytics wird nur konfiguriert, wenn der Verantwortliche die Mess-ID auf dem Server einrichtet.
|
||||||
table2_col_name: Name
|
table2_col_name: Name
|
||||||
@@ -185,6 +185,10 @@ de:
|
|||||||
s4_2_row3_purpose: Unterscheidet Nutzer (Statistiken)
|
s4_2_row3_purpose: Unterscheidet Nutzer (Statistiken)
|
||||||
s4_2_row3_duration: 24 Stunden
|
s4_2_row3_duration: 24 Stunden
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
|
s4_2_row4_name_html: "Heatmap / Scroll (First-Party)"
|
||||||
|
s4_2_row4_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_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_privacy_link_text: Google-Datenschutzerklärung
|
||||||
s4_2_google_optout_link_text: Analytics-Deaktivierungs-Add-on
|
s4_2_google_optout_link_text: Analytics-Deaktivierungs-Add-on
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ en:
|
|||||||
s10_title: 10. Security
|
s10_title: 10. Security
|
||||||
s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us."
|
s10_body: "We adopt appropriate technical and organisational measures (authenticated access, encrypted passwords, HTTPS communications, environment segregation, restricted staff access). No system is invulnerable: if you suspect unauthorised access, change your password and contact us."
|
||||||
s11_title: 11. Cookies and similar technologies
|
s11_title: 11. Cookies and similar technologies
|
||||||
s11_p1_html: "The site uses necessary technical cookies (login session, security, storage of cookie preferences) and, subject to consent via the banner, <strong>Google Analytics</strong> 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_p2_html: "You can manage your preferences at any time via the “Manage cookies” link in the footer or consult the %{cookie_policy_link}."
|
||||||
s11_cookie_policy_link_text: Cookie policy
|
s11_cookie_policy_link_text: Cookie policy
|
||||||
s12_title: 12. Changes
|
s12_title: 12. Changes
|
||||||
@@ -169,7 +169,7 @@ en:
|
|||||||
s4_1_row2_duration: 12 months
|
s4_1_row2_duration: 12 months
|
||||||
s4_1_row2_provider: Match Live TV (first-party)
|
s4_1_row2_provider: Match Live TV (first-party)
|
||||||
s4_2_title: 4.2 Analytics (only with your consent)
|
s4_2_title: 4.2 Analytics (only with your consent)
|
||||||
s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load <strong>Google Analytics 4</strong> to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies."
|
s4_2_p1_html: "If you accept “All cookies” or enable statistics in the banner, we load <strong>Google Analytics 4</strong> to understand how the site is used (pages visited, aggregated origin, device). Data is processed by Google Ireland Limited / Google LLC in accordance with their policies. With the same consent we also collect <strong>first-party aggregated statistics</strong> (mouse movements, clicks and scroll depth, without identifying the user) for internal heatmaps used to improve the product and platform marketing. We do not track the admin area, control room, live player or replay pages."
|
||||||
s4_2_active_html: "Measurement ID active on the site: <code>%{id}</code>"
|
s4_2_active_html: "Measurement ID active on the site: <code>%{id}</code>"
|
||||||
s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server.
|
s4_2_inactive: Google Analytics is only configured when the controller sets the measurement ID on the server.
|
||||||
table2_col_name: Name
|
table2_col_name: Name
|
||||||
@@ -185,6 +185,10 @@ en:
|
|||||||
s4_2_row3_purpose: Distinguishes users (statistics)
|
s4_2_row3_purpose: Distinguishes users (statistics)
|
||||||
s4_2_row3_duration: 24 hours
|
s4_2_row3_duration: 24 hours
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
|
s4_2_row4_name_html: "Heatmap / scroll (first-party)"
|
||||||
|
s4_2_row4_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_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_privacy_link_text: Google Privacy Policy
|
||||||
s4_2_google_optout_link_text: Analytics opt-out add-on
|
s4_2_google_optout_link_text: Analytics opt-out add-on
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ es:
|
|||||||
s10_title: 10. Seguridad
|
s10_title: 10. Seguridad
|
||||||
s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos."
|
s10_body: "Adoptamos medidas técnicas y organizativas adecuadas (acceso autenticado, contraseñas cifradas, comunicaciones HTTPS, segregación de entornos, acceso restringido del personal). Ningún sistema es invulnerable: si sospechas un acceso no autorizado, cambia la contraseña y contáctanos."
|
||||||
s11_title: 11. Cookies y tecnologías similares
|
s11_title: 11. Cookies y tecnologías similares
|
||||||
s11_p1_html: "El sitio utiliza cookies técnicas necesarias (sesión de acceso, seguridad, almacenamiento de preferencias de cookies) y, previo consentimiento a través del banner, <strong>Google Analytics</strong> 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_p2_html: "Puedes gestionar tus preferencias en cualquier momento desde el enlace «Gestionar cookies» en el pie de página o consultar la %{cookie_policy_link}."
|
||||||
s11_cookie_policy_link_text: política de cookies
|
s11_cookie_policy_link_text: política de cookies
|
||||||
s12_title: 12. Modificaciones
|
s12_title: 12. Modificaciones
|
||||||
@@ -169,7 +169,7 @@ es:
|
|||||||
s4_1_row2_duration: 12 meses
|
s4_1_row2_duration: 12 meses
|
||||||
s4_1_row2_provider: Match Live TV (propia)
|
s4_1_row2_provider: Match Live TV (propia)
|
||||||
s4_2_title: 4.2 Analíticas (solo con tu consentimiento)
|
s4_2_title: 4.2 Analíticas (solo con tu consentimiento)
|
||||||
s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos <strong>Google Analytics 4</strong> para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas."
|
s4_2_p1_html: "Si aceptas «Todas las cookies» o activas las estadísticas en el banner, cargamos <strong>Google Analytics 4</strong> para entender cómo se usa el sitio (páginas visitadas, procedencia agregada, dispositivo). Los datos son tratados por Google Ireland Limited / Google LLC conforme a sus políticas. Con el mismo consentimiento también recopilamos <strong>estadísticas agregadas de primera parte</strong> (movimientos del ratón, clics y profundidad de scroll, sin identificar al usuario) para heatmaps internas de mejora del producto y marketing de la plataforma. No rastreamos el área admin, la regia, el player en directo ni las páginas de replay."
|
||||||
s4_2_active_html: "ID de medición activo en el sitio: <code>%{id}</code>"
|
s4_2_active_html: "ID de medición activo en el sitio: <code>%{id}</code>"
|
||||||
s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor.
|
s4_2_inactive: Google Analytics solo se configura cuando el responsable establece el ID de medición en el servidor.
|
||||||
table2_col_name: Nombre
|
table2_col_name: Nombre
|
||||||
@@ -185,6 +185,10 @@ es:
|
|||||||
s4_2_row3_purpose: Distingue a los usuarios (estadísticas)
|
s4_2_row3_purpose: Distingue a los usuarios (estadísticas)
|
||||||
s4_2_row3_duration: 24 horas
|
s4_2_row3_duration: 24 horas
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
|
s4_2_row4_name_html: "Heatmap / scroll (propia)"
|
||||||
|
s4_2_row4_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_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_privacy_link_text: Política de privacidad de Google
|
||||||
s4_2_google_optout_link_text: complemento de exclusión de Analytics
|
s4_2_google_optout_link_text: complemento de exclusión de Analytics
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ fr:
|
|||||||
s10_title: 10. Sécurité
|
s10_title: 10. Sécurité
|
||||||
s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous."
|
s10_body: "Nous adoptons des mesures techniques et organisationnelles appropriées (accès authentifié, mots de passe chiffrés, communications HTTPS, séparation des environnements, accès du staff limité). Aucun système n'est invulnérable : si vous suspectez un accès non autorisé, changez votre mot de passe et contactez-nous."
|
||||||
s11_title: 11. Cookies et technologies similaires
|
s11_title: 11. Cookies et technologies similaires
|
||||||
s11_p1_html: "Le site utilise des cookies techniques nécessaires (session de connexion, sécurité, mémorisation des préférences cookies) et, sous réserve de consentement via la bannière, <strong>Google Analytics</strong> 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_p2_html: "Vous pouvez gérer vos préférences à tout moment via le lien « Gérer les cookies » dans le pied de page ou consulter la %{cookie_policy_link}."
|
||||||
s11_cookie_policy_link_text: politique de cookies
|
s11_cookie_policy_link_text: politique de cookies
|
||||||
s12_title: 12. Modifications
|
s12_title: 12. Modifications
|
||||||
@@ -169,7 +169,7 @@ fr:
|
|||||||
s4_1_row2_duration: 12 mois
|
s4_1_row2_duration: 12 mois
|
||||||
s4_1_row2_provider: Match Live TV (première partie)
|
s4_1_row2_provider: Match Live TV (première partie)
|
||||||
s4_2_title: 4.2 Analytics (uniquement avec votre consentement)
|
s4_2_title: 4.2 Analytics (uniquement avec votre consentement)
|
||||||
s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons <strong>Google Analytics 4</strong> pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques."
|
s4_2_p1_html: "Si vous acceptez « Tous les cookies » ou activez les statistiques dans la bannière, nous chargeons <strong>Google Analytics 4</strong> pour comprendre comment le site est utilisé (pages visitées, provenance agrégée, appareil). Les données sont traitées par Google Ireland Limited / Google LLC conformément à leurs politiques. Avec le même consentement, nous collectons aussi des <strong>statistiques agrégées first-party</strong> (mouvements de souris, clics et profondeur de scroll, sans identifier l'utilisateur) pour des heatmaps internes d'amélioration produit et marketing plateforme. Nous ne suivons pas l'admin, la régie, le player live ni les pages replay."
|
||||||
s4_2_active_html: "ID de mesure actif sur le site : <code>%{id}</code>"
|
s4_2_active_html: "ID de mesure actif sur le site : <code>%{id}</code>"
|
||||||
s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur.
|
s4_2_inactive: Google Analytics n'est configuré que lorsque le responsable définit l'ID de mesure sur le serveur.
|
||||||
table2_col_name: Nom
|
table2_col_name: Nom
|
||||||
@@ -185,6 +185,10 @@ fr:
|
|||||||
s4_2_row3_purpose: Distingue les utilisateurs (statistiques)
|
s4_2_row3_purpose: Distingue les utilisateurs (statistiques)
|
||||||
s4_2_row3_duration: 24 heures
|
s4_2_row3_duration: 24 heures
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
|
s4_2_row4_name_html: "Heatmap / scroll (première partie)"
|
||||||
|
s4_2_row4_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_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_privacy_link_text: Politique de confidentialité Google
|
||||||
s4_2_google_optout_link_text: module de désactivation Analytics
|
s4_2_google_optout_link_text: module de désactivation Analytics
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ it:
|
|||||||
s10_title: 10. Sicurezza
|
s10_title: 10. Sicurezza
|
||||||
s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci."
|
s10_body: "Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate, comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile: se sospetti un accesso non autorizzato, cambia password e contattaci."
|
||||||
s11_title: 11. Cookie e tecnologie simili
|
s11_title: 11. Cookie e tecnologie simili
|
||||||
s11_p1_html: "Il sito utilizza cookie tecnici necessari (sessione di login, sicurezza, memorizzazione delle preferenze cookie) e, previo consenso tramite il banner, <strong>Google Analytics</strong> 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_p2_html: "Puoi gestire le preferenze in qualsiasi momento dal link «Gestisci cookie» nel footer o consultare la %{cookie_policy_link}."
|
||||||
s11_cookie_policy_link_text: Cookie policy
|
s11_cookie_policy_link_text: Cookie policy
|
||||||
s12_title: 12. Modifiche
|
s12_title: 12. Modifiche
|
||||||
@@ -169,7 +169,7 @@ it:
|
|||||||
s4_1_row2_duration: 12 mesi
|
s4_1_row2_duration: 12 mesi
|
||||||
s4_1_row2_provider: Match Live TV (prima parte)
|
s4_1_row2_provider: Match Live TV (prima parte)
|
||||||
s4_2_title: 4.2 Analytics (solo con il tuo consenso)
|
s4_2_title: 4.2 Analytics (solo con il tuo consenso)
|
||||||
s4_2_p1_html: "Se accetti «Tutti i cookie» o abiliti le statistiche nel banner, carichiamo <strong>Google Analytics 4</strong> per capire come viene usato il sito (pagine visitate, provenienza aggregata, dispositivo). I dati sono trattati da Google Ireland Limited / Google LLC secondo le loro policy."
|
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 l’utente) per heatmaps interne usate dal titolare a fini di miglioramento del prodotto e marketing della piattaforma. Non tracciamo area admin, regia, player live né replay."
|
||||||
s4_2_active_html: "ID misurazione attivo sul sito: <code>%{id}</code>"
|
s4_2_active_html: "ID misurazione attivo sul sito: <code>%{id}</code>"
|
||||||
s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta l’ID misurazione sul server.
|
s4_2_inactive: Google Analytics è configurato solo quando il titolare imposta l’ID misurazione sul server.
|
||||||
table2_col_name: Nome
|
table2_col_name: Nome
|
||||||
@@ -185,6 +185,10 @@ it:
|
|||||||
s4_2_row3_purpose: Distingue gli utenti (statistiche)
|
s4_2_row3_purpose: Distingue gli utenti (statistiche)
|
||||||
s4_2_row3_duration: 24 ore
|
s4_2_row3_duration: 24 ore
|
||||||
s4_2_row3_provider: Google
|
s4_2_row3_provider: Google
|
||||||
|
s4_2_row4_name_html: "Heatmap / scroll (prima parte)"
|
||||||
|
s4_2_row4_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_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_privacy_link_text: Privacy Policy Google
|
||||||
s4_2_google_optout_link_text: componente opt-out Analytics
|
s4_2_google_optout_link_text: componente opt-out Analytics
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
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
|
namespace :admin do
|
||||||
get "login", to: "auth#new", as: :login
|
get "login", to: "auth#new", as: :login
|
||||||
post "login", to: "auth#create"
|
post "login", to: "auth#create"
|
||||||
@@ -127,6 +130,8 @@ Rails.application.routes.draw do
|
|||||||
delete :clear_kill_switch
|
delete :clear_kill_switch
|
||||||
end
|
end
|
||||||
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
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -184,7 +189,7 @@ Rails.application.routes.draw do
|
|||||||
post "signup", to: "registrations#create"
|
post "signup", to: "registrations#create"
|
||||||
get "login", to: "sessions#new"
|
get "login", to: "sessions#new"
|
||||||
post "login", to: "sessions#create"
|
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
|
patch "locale", to: "locales#update", as: :locale
|
||||||
get "password/forgot", to: "password_resets#new", as: :password_forgot
|
get "password/forgot", to: "password_resets#new", as: :password_forgot
|
||||||
post "password/forgot", to: "password_resets#create"
|
post "password/forgot", to: "password_resets#create"
|
||||||
|
|||||||
@@ -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
|
||||||
Generated
+56
-2
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2026_08_20_140000) 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
@@ -51,11 +51,62 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_140000) do
|
|||||||
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
|
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
|
||||||
end
|
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|
|
create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.string "kind", default: "info", null: false
|
t.string "kind", default: "info", null: false
|
||||||
t.string "severity", default: "info", null: false
|
t.string "severity", default: "info", null: false
|
||||||
t.string "status", default: "draft", null: false
|
t.string "status", default: "draft", null: false
|
||||||
t.boolean "show_on_app", default: true, null: false
|
|
||||||
t.boolean "show_on_web_public", default: false, null: false
|
t.boolean "show_on_web_public", default: false, null: false
|
||||||
t.boolean "show_on_web_private", default: false, null: false
|
t.boolean "show_on_web_private", default: false, null: false
|
||||||
t.string "title", null: false
|
t.string "title", null: false
|
||||||
@@ -68,6 +119,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_140000) do
|
|||||||
t.uuid "created_by_admin_id"
|
t.uuid "created_by_admin_id"
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
|
t.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 ["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", "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"
|
t.index ["status"], name: "index_app_announcements_on_status"
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -206,6 +206,7 @@ body.admin-body {
|
|||||||
.admin-table td {
|
.admin-table td {
|
||||||
padding: 0.65rem 0.5rem;
|
padding: 0.65rem 0.5rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
border-bottom: 1px solid var(--card-border);
|
border-bottom: 1px solid var(--card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +231,23 @@ body.admin-body {
|
|||||||
.badge--paused { background: #555; color: #fff; }
|
.badge--paused { background: #555; color: #fff; }
|
||||||
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||||
.badge--ok { 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 {
|
.team-list {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
@@ -307,9 +325,11 @@ body.admin-body {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flex sul contenuto, non sulla <td>: altrimenti si rompe vertical-align della riga. */
|
||||||
.admin-link-compact {
|
.admin-link-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -652,6 +672,66 @@ body.admin-body {
|
|||||||
color: var(--muted);
|
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 {
|
.admin-form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -665,3 +745,275 @@ body.admin-body {
|
|||||||
grid-template-columns: 1fr;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,9 @@
|
|||||||
function applyConsent(consent) {
|
function applyConsent(consent) {
|
||||||
if (consent && consent.analytics) {
|
if (consent && consent.analytics) {
|
||||||
loadGoogleAnalytics();
|
loadGoogleAnalytics();
|
||||||
|
if (typeof window.mltvSiteAnalyticsStart === "function") {
|
||||||
|
window.mltvSiteAnalyticsStart();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
+20
File diff suppressed because one or more lines are too long
@@ -10,7 +10,8 @@ RSpec.describe AppAnnouncement do
|
|||||||
status: "published",
|
status: "published",
|
||||||
title: "Avviso",
|
title: "Avviso",
|
||||||
body: "Testo",
|
body: "Testo",
|
||||||
show_on_app: true,
|
show_on_android: true,
|
||||||
|
show_on_ios: true,
|
||||||
show_on_web_public: false,
|
show_on_web_public: false,
|
||||||
show_on_web_private: false
|
show_on_web_private: false
|
||||||
}.merge(attrs))
|
}.merge(attrs))
|
||||||
@@ -35,22 +36,44 @@ RSpec.describe AppAnnouncement do
|
|||||||
|
|
||||||
it "filters by selected channels" do
|
it "filters by selected channels" do
|
||||||
public_item = create_notice!(
|
public_item = create_notice!(
|
||||||
title: "Pubblico", show_on_app: false, show_on_web_public: true
|
title: "Pubblico", show_on_android: false, show_on_ios: false, show_on_web_public: true
|
||||||
)
|
)
|
||||||
private_item = create_notice!(
|
private_item = create_notice!(
|
||||||
title: "Privato", show_on_app: false, show_on_web_private: true
|
title: "Privato", show_on_android: false, show_on_ios: false, show_on_web_private: true
|
||||||
)
|
)
|
||||||
app_item = create_notice!(title: "App")
|
android_item = create_notice!(title: "Android", show_on_ios: false)
|
||||||
|
ios_item = create_notice!(title: "iOS", show_on_android: false, show_on_ios: true)
|
||||||
|
|
||||||
expect(described_class.for_channel(:web_public)).to contain_exactly(public_item)
|
expect(described_class.for_channel(:web_public)).to contain_exactly(public_item)
|
||||||
expect(described_class.for_channel(:web_private)).to contain_exactly(private_item)
|
expect(described_class.for_channel(:web_private)).to contain_exactly(private_item)
|
||||||
expect(described_class.for_app).to contain_exactly(app_item)
|
expect(described_class.for_platform("android")).to contain_exactly(android_item)
|
||||||
|
expect(described_class.for_platform("ios")).to contain_exactly(ios_item)
|
||||||
|
expect(described_class.for_app).to contain_exactly(android_item, ios_item)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "serves translated copy and falls back to English then Italian" do
|
||||||
|
item = create_notice!(
|
||||||
|
title: "Titolo IT",
|
||||||
|
body: "Testo IT",
|
||||||
|
translations: {
|
||||||
|
"en" => { "title" => "EN title", "body" => "EN body" },
|
||||||
|
"de" => { "title" => "DE title", "body" => "DE body" }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(item.as_api_json(locale: :en)[:title]).to eq("EN title")
|
||||||
|
expect(item.as_api_json(locale: :de)[:title]).to eq("DE title")
|
||||||
|
expect(item.as_api_json(locale: :fr)[:title]).to eq("EN title")
|
||||||
|
expect(item.as_api_json(locale: :it)[:title]).to eq("Titolo IT")
|
||||||
|
expect(item.filled_locale_codes).to include("it", "en", "de")
|
||||||
|
expect(item.filled_locale_codes).not_to include("fr")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "requires at least one channel" do
|
it "requires at least one channel" do
|
||||||
item = described_class.new(
|
item = described_class.new(
|
||||||
kind: "info", status: "draft", title: "X", body: "Y",
|
kind: "info", status: "draft", title: "X", body: "Y",
|
||||||
show_on_app: false, show_on_web_public: false, show_on_web_private: false
|
show_on_android: false, show_on_ios: false,
|
||||||
|
show_on_web_public: false, show_on_web_private: false
|
||||||
)
|
)
|
||||||
expect(item).not_to be_valid
|
expect(item).not_to be_valid
|
||||||
expect(item.errors[:base]).to be_present
|
expect(item.errors[:base]).to be_present
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Admin analytics", type: :request do
|
||||||
|
let!(:admin) { AdminAccount.create!(username: "ops-analytics", password: "Password123") }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||||
|
AnalyticsPageStat.create!(
|
||||||
|
day: Time.zone.today,
|
||||||
|
page_path: "/prezzi",
|
||||||
|
device: "desktop",
|
||||||
|
pageview_count: 12,
|
||||||
|
scroll_samples: 10,
|
||||||
|
scroll_sum_pct: 600,
|
||||||
|
max_scroll_pct: 90
|
||||||
|
)
|
||||||
|
AnalyticsPageCell.create!(
|
||||||
|
day: Time.zone.today,
|
||||||
|
page_path: "/prezzi",
|
||||||
|
device: "desktop",
|
||||||
|
cell_x: 10,
|
||||||
|
cell_y: 15,
|
||||||
|
click_count: 4,
|
||||||
|
move_count: 40
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra l'elenco pagine" do
|
||||||
|
get admin_analytics_path
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("/prezzi")
|
||||||
|
expect(response.body).to include("12")
|
||||||
|
expect(response.body).to include("40")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra la heatmap di una pagina" do
|
||||||
|
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("admin-heatmap")
|
||||||
|
expect(response.body).to include("/prezzi")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -18,7 +18,8 @@ RSpec.describe "Admin announcements", type: :request do
|
|||||||
title: "Nuova versione",
|
title: "Nuova versione",
|
||||||
body: "Aggiorna l'app dallo store.",
|
body: "Aggiorna l'app dallo store.",
|
||||||
dismissible: "1",
|
dismissible: "1",
|
||||||
show_on_app: "1",
|
show_on_android: "1",
|
||||||
|
show_on_ios: "1",
|
||||||
show_on_web_public: "1",
|
show_on_web_public: "1",
|
||||||
show_on_web_private: "0"
|
show_on_web_private: "0"
|
||||||
}
|
}
|
||||||
@@ -28,7 +29,8 @@ RSpec.describe "Admin announcements", type: :request do
|
|||||||
notice = AppAnnouncement.last
|
notice = AppAnnouncement.last
|
||||||
expect(notice.title).to eq("Nuova versione")
|
expect(notice.title).to eq("Nuova versione")
|
||||||
expect(notice).to be_published
|
expect(notice).to be_published
|
||||||
expect(notice.show_on_app).to be(true)
|
expect(notice.show_on_android).to be(true)
|
||||||
|
expect(notice.show_on_ios).to be(true)
|
||||||
expect(notice.show_on_web_public).to be(true)
|
expect(notice.show_on_web_public).to be(true)
|
||||||
expect(notice.show_on_web_private).to be(false)
|
expect(notice.show_on_web_private).to be(false)
|
||||||
|
|
||||||
@@ -37,6 +39,42 @@ RSpec.describe "Admin announcements", type: :request do
|
|||||||
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
|
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "salva le traduzioni e mostra le schede lingua nel form" do
|
||||||
|
get new_admin_announcement_path
|
||||||
|
expect(response.body).to include(I18n.t("admin.announcements.form.locales_legend"))
|
||||||
|
expect(response.body).to include("English")
|
||||||
|
expect(response.body).to include("Français")
|
||||||
|
expect(response.body).to include("Deutsch")
|
||||||
|
expect(response.body).to include("Español")
|
||||||
|
expect(response.body).not_to include("name=\"app_announcement[android_title]\"")
|
||||||
|
expect(response.body).not_to include("name=\"app_announcement[ios_title]\"")
|
||||||
|
|
||||||
|
post admin_announcements_path, params: {
|
||||||
|
app_announcement: {
|
||||||
|
kind: "info",
|
||||||
|
severity: "info",
|
||||||
|
status: "published",
|
||||||
|
title: "Nuova versione",
|
||||||
|
body: "Aggiorna l'app dallo store.",
|
||||||
|
dismissible: "1",
|
||||||
|
show_on_android: "1",
|
||||||
|
show_on_ios: "1",
|
||||||
|
show_on_web_public: "1",
|
||||||
|
show_on_web_private: "0",
|
||||||
|
translations: {
|
||||||
|
en: { title: "A new version is out", body: "Update the app from the store." },
|
||||||
|
fr: { title: "Nouvelle version", body: "Mettez à jour l'application." }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to redirect_to(admin_announcements_path)
|
||||||
|
notice = AppAnnouncement.last
|
||||||
|
expect(notice.translations["en"]["title"]).to eq("A new version is out")
|
||||||
|
expect(notice.translations["fr"]["title"]).to eq("Nouvelle version")
|
||||||
|
expect(notice.as_api_json(locale: :en)[:title]).to eq("A new version is out")
|
||||||
|
end
|
||||||
|
|
||||||
it "elimina un avviso" do
|
it "elimina un avviso" do
|
||||||
item = AppAnnouncement.create!(
|
item = AppAnnouncement.create!(
|
||||||
kind: "info", status: "published", title: "Da togliere", body: "Test"
|
kind: "info", status: "published", title: "Da togliere", body: "Test"
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Admin sessions index", type: :request do
|
||||||
|
let!(:admin) { AdminAccount.create!(username: "ops-sessions", password: "Password123") }
|
||||||
|
let!(:coach) do
|
||||||
|
User.create!(email: "sessions-coach@test.it", name: "Coach", password: "Password123", role: "coach")
|
||||||
|
end
|
||||||
|
let!(:club_a) { Club.create!(name: "Tigers Club", sport: "volleyball") }
|
||||||
|
let!(:club_b) { Club.create!(name: "Other Club", sport: "volleyball") }
|
||||||
|
let!(:team_a) { club_a.teams.create!(name: "Tigers U16", sport_key: "pallavolo") }
|
||||||
|
let!(:team_b) { club_b.teams.create!(name: "Other U14", sport_key: "pallavolo") }
|
||||||
|
let!(:match_a) { team_a.matches.create!(opponent_name: "Rival A", sport_key: "pallavolo") }
|
||||||
|
let!(:match_b) { team_b.matches.create!(opponent_name: "Rival B", sport_key: "pallavolo") }
|
||||||
|
let!(:session_a) do
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match_a,
|
||||||
|
user: coach,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "ended",
|
||||||
|
started_at: 2.hours.ago,
|
||||||
|
ended_at: 1.hour.ago,
|
||||||
|
total_duration_secs: 3600
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let!(:session_b) do
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match_b,
|
||||||
|
user: coach,
|
||||||
|
platform: "youtube",
|
||||||
|
status: "live",
|
||||||
|
started_at: 30.minutes.ago
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def login!
|
||||||
|
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||||
|
end
|
||||||
|
|
||||||
|
before { login! }
|
||||||
|
|
||||||
|
it "mostra società, orari e filtra per testo" do
|
||||||
|
get admin_sessions_path, params: { q: "Tigers" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Tigers Club")
|
||||||
|
expect(response.body).to include("Tigers U16 vs Rival A")
|
||||||
|
expect(response.body).not_to include("Other U14 vs Rival B")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "filtra per stato" do
|
||||||
|
get admin_sessions_path, params: { status: "live" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Other U14 vs Rival B")
|
||||||
|
expect(response.body).not_to include("Tigers U16 vs Rival A")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra il dettaglio formattato" do
|
||||||
|
get admin_session_path(session_a)
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Tigers Club")
|
||||||
|
expect(response.body).to include("Dettaglio sessione").or include("Session details")
|
||||||
|
expect(response.body).to include(session_a.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Analytics ingest", type: :request do
|
||||||
|
it "accetta eventi validi, normalizza il path e aggrega" do
|
||||||
|
post "/analytics/events",
|
||||||
|
params: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
type: "pageview",
|
||||||
|
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||||
|
device: "desktop",
|
||||||
|
ts: Time.current.to_i * 1000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "click",
|
||||||
|
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||||
|
device: "desktop",
|
||||||
|
x: 25,
|
||||||
|
y: 50,
|
||||||
|
ts: Time.current.to_i * 1000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "move",
|
||||||
|
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||||
|
device: "desktop",
|
||||||
|
x: 40,
|
||||||
|
y: 30,
|
||||||
|
n: 12,
|
||||||
|
ts: Time.current.to_i * 1000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "scroll",
|
||||||
|
path: "/clubs/461d369a-104c-4d3c-8860-1518b4e95d35",
|
||||||
|
device: "desktop",
|
||||||
|
scroll: 80,
|
||||||
|
ts: Time.current.to_i * 1000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["accepted"]).to eq(4)
|
||||||
|
|
||||||
|
expect(AnalyticsEvent.count).to eq(0)
|
||||||
|
expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").pageview_count).to eq(1)
|
||||||
|
expect(AnalyticsPageStat.find_by(page_path: "/clubs/:id").max_scroll_pct).to eq(80)
|
||||||
|
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:click_count)).to eq(1)
|
||||||
|
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta path esclusi" do
|
||||||
|
post "/analytics/events",
|
||||||
|
params: {
|
||||||
|
events: [
|
||||||
|
{ type: "pageview", path: "/admin/ops", device: "desktop", ts: Time.current.to_i * 1000 },
|
||||||
|
{ type: "pageview", path: "/live/461d369a-104c-4d3c-8860-1518b4e95d35", device: "mobile", ts: Time.current.to_i * 1000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
as: :json
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
expect(JSON.parse(response.body)["accepted"]).to eq(0)
|
||||||
|
expect(AnalyticsEvent.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Analytics snapshots", type: :request do
|
||||||
|
def jpeg_upload
|
||||||
|
path = Rails.root.join("tmp/analytics-snap-test.jpg")
|
||||||
|
FileUtils.mkdir_p(path.dirname)
|
||||||
|
# minimal valid-ish jpeg bytes for ActiveStorage
|
||||||
|
File.binwrite(path, "\xFF\xD8\xFF\xE0#{'x' * 12_000}\xFF\xD9")
|
||||||
|
Rack::Test::UploadedFile.new(path, "image/jpeg")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accetta uno screenshot e lo associa al path" do
|
||||||
|
post "/analytics/snapshot",
|
||||||
|
params: {
|
||||||
|
path: "/prezzi",
|
||||||
|
device: "desktop",
|
||||||
|
width: 1440,
|
||||||
|
height: 2200,
|
||||||
|
image: jpeg_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
snap = AnalyticsPageSnapshot.find_by(page_path: "/prezzi", device: "desktop")
|
||||||
|
expect(snap).to be_present
|
||||||
|
expect(snap.image).to be_attached
|
||||||
|
expect(snap.width).to eq(1440)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "rifiuta path esclusi" do
|
||||||
|
post "/analytics/snapshot",
|
||||||
|
params: {
|
||||||
|
path: "/admin/analytics",
|
||||||
|
device: "desktop",
|
||||||
|
width: 1440,
|
||||||
|
height: 900,
|
||||||
|
image: jpeg_upload
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
|
expect(AnalyticsPageSnapshot.count).to eq(0)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -3,21 +3,23 @@
|
|||||||
require "rails_helper"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Api::V1::Announcements", type: :request do
|
RSpec.describe "Api::V1::Announcements", type: :request do
|
||||||
it "GET /api/v1/announcements senza autenticazione restituisce solo gli avvisi attivi per l'app" do
|
it "GET /api/v1/announcements senza autenticazione restituisce solo gli avvisi attivi per la piattaforma" do
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "maintenance",
|
kind: "maintenance",
|
||||||
severity: "warning",
|
severity: "warning",
|
||||||
status: "published",
|
status: "published",
|
||||||
title: "Manutenzione stasera",
|
title: "Manutenzione stasera",
|
||||||
body: "Dalle 23:00 alle 01:00 il servizio sarà offline.",
|
body: "Dalle 23:00 alle 01:00 il servizio sarà offline.",
|
||||||
show_on_app: true
|
show_on_android: true,
|
||||||
|
show_on_ios: true
|
||||||
)
|
)
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "info",
|
kind: "info",
|
||||||
status: "published",
|
status: "published",
|
||||||
title: "Solo sito",
|
title: "Solo sito",
|
||||||
body: "Non in app",
|
body: "Non in app",
|
||||||
show_on_app: false,
|
show_on_android: false,
|
||||||
|
show_on_ios: false,
|
||||||
show_on_web_public: true
|
show_on_web_public: true
|
||||||
)
|
)
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
@@ -26,14 +28,50 @@ RSpec.describe "Api::V1::Announcements", type: :request do
|
|||||||
title: "Bozza",
|
title: "Bozza",
|
||||||
body: "Non deve uscire"
|
body: "Non deve uscire"
|
||||||
)
|
)
|
||||||
|
AppAnnouncement.create!(
|
||||||
|
kind: "update",
|
||||||
|
status: "published",
|
||||||
|
title: "Aggiorna Android",
|
||||||
|
body: "Versione nuova",
|
||||||
|
show_on_android: true,
|
||||||
|
show_on_ios: false
|
||||||
|
)
|
||||||
|
|
||||||
get "/api/v1/announcements", params: { platform: "android" }
|
get "/api/v1/announcements", params: { platform: "android" }
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
body = JSON.parse(response.body)
|
android_body = JSON.parse(response.body)
|
||||||
expect(body.length).to eq(1)
|
expect(android_body.map { |row| row["title"] }).to contain_exactly("Manutenzione stasera", "Aggiorna Android")
|
||||||
expect(body.first["title"]).to eq("Manutenzione stasera")
|
|
||||||
expect(body.first["kind"]).to eq("maintenance")
|
get "/api/v1/announcements", params: { platform: "ios" }
|
||||||
expect(body.first["dismissible"]).to eq(true)
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
ios_body = JSON.parse(response.body)
|
||||||
|
expect(ios_body.map { |row| row["title"] }).to eq(["Manutenzione stasera"])
|
||||||
|
end
|
||||||
|
|
||||||
|
it "GET /api/v1/announcements localizza il testo da Accept-Language" do
|
||||||
|
AppAnnouncement.create!(
|
||||||
|
kind: "info",
|
||||||
|
status: "published",
|
||||||
|
title: "Manutenzione stasera",
|
||||||
|
body: "Dalle 23:00 il servizio sarà offline.",
|
||||||
|
show_on_android: true,
|
||||||
|
show_on_ios: true,
|
||||||
|
translations: {
|
||||||
|
"en" => { "title" => "Maintenance tonight", "body" => "The service will be offline from 23:00." }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
get "/api/v1/announcements", params: { platform: "android" }, headers: { "Accept-Language" => "en-US,en;q=0.9" }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
payload = JSON.parse(response.body)
|
||||||
|
expect(payload.first["title"]).to eq("Maintenance tonight")
|
||||||
|
expect(payload.first["body"]).to include("offline")
|
||||||
|
|
||||||
|
get "/api/v1/announcements", params: { platform: "android" }, headers: { "Accept-Language" => "fr" }
|
||||||
|
|
||||||
|
expect(JSON.parse(response.body).first["title"]).to eq("Maintenance tonight")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ RSpec.describe "Public site announcements", type: :request do
|
|||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "maintenance", status: "published", title: "Manutenzione sito",
|
kind: "maintenance", status: "published", title: "Manutenzione sito",
|
||||||
body: "Stasera il sito sarà offline.",
|
body: "Stasera il sito sarà offline.",
|
||||||
show_on_app: false, show_on_web_public: true, show_on_web_private: false
|
show_on_android: false, show_on_ios: false, show_on_web_public: true, show_on_web_private: false
|
||||||
)
|
)
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "info", status: "published", title: "Solo soci",
|
kind: "info", status: "published", title: "Solo soci",
|
||||||
body: "Messaggio interno.",
|
body: "Messaggio interno.",
|
||||||
show_on_app: false, show_on_web_public: false, show_on_web_private: true
|
show_on_android: false, show_on_ios: false, show_on_web_public: false, show_on_web_private: true
|
||||||
)
|
)
|
||||||
|
|
||||||
get root_path
|
get root_path
|
||||||
@@ -26,16 +26,34 @@ RSpec.describe "Public site announcements", type: :request do
|
|||||||
expect(response.body).not_to include("Solo soci")
|
expect(response.body).not_to include("Solo soci")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "mostra la traduzione inglese sul sito pubblico" do
|
||||||
|
AppAnnouncement.create!(
|
||||||
|
kind: "maintenance", status: "published", title: "Manutenzione sito",
|
||||||
|
body: "Stasera il sito sarà offline.",
|
||||||
|
show_on_android: false, show_on_ios: false, show_on_web_public: true, show_on_web_private: false,
|
||||||
|
translations: {
|
||||||
|
"en" => { "title" => "Website maintenance", "body" => "The site will be offline tonight." }
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
cookies[:mltv_locale] = "en"
|
||||||
|
get root_path
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("Website maintenance")
|
||||||
|
expect(response.body).not_to include("Manutenzione sito")
|
||||||
|
end
|
||||||
|
|
||||||
it "mostra gli avvisi privati nell'area account dopo il login" do
|
it "mostra gli avvisi privati nell'area account dopo il login" do
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "info", status: "published", title: "Solo soci",
|
kind: "info", status: "published", title: "Solo soci",
|
||||||
body: "Messaggio interno.",
|
body: "Messaggio interno.",
|
||||||
show_on_app: false, show_on_web_public: false, show_on_web_private: true
|
show_on_android: false, show_on_ios: false, show_on_web_public: false, show_on_web_private: true
|
||||||
)
|
)
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "info", status: "published", title: "Manutenzione sito",
|
kind: "info", status: "published", title: "Manutenzione sito",
|
||||||
body: "Stasera il sito sarà offline.",
|
body: "Stasera il sito sarà offline.",
|
||||||
show_on_app: false, show_on_web_public: true, show_on_web_private: false
|
show_on_android: false, show_on_ios: false, show_on_web_public: true, show_on_web_private: false
|
||||||
)
|
)
|
||||||
|
|
||||||
post public_login_path, params: { email: coach.email, password: "Password123" }
|
post public_login_path, params: { email: coach.email, password: "Password123" }
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Public logout", type: :request do
|
||||||
|
let!(:user) do
|
||||||
|
User.create!(email: "logout-user@test.it", name: "Logout", password: "Password123", role: "coach")
|
||||||
|
end
|
||||||
|
|
||||||
|
def login!
|
||||||
|
ActionController::Base.allow_forgery_protection = false
|
||||||
|
post public_login_path, params: { email: user.email, password: "Password123" }
|
||||||
|
ActionController::Base.allow_forgery_protection = true
|
||||||
|
end
|
||||||
|
|
||||||
|
around do |example|
|
||||||
|
was = ActionController::Base.allow_forgery_protection
|
||||||
|
example.run
|
||||||
|
ensure
|
||||||
|
ActionController::Base.allow_forgery_protection = was
|
||||||
|
end
|
||||||
|
|
||||||
|
it "disconnette anche senza authenticity_token (CSRF stale)" do
|
||||||
|
login!
|
||||||
|
expect(session[:user_id]).to eq(user.id)
|
||||||
|
|
||||||
|
delete "/logout"
|
||||||
|
expect(response).to redirect_to(public_pricing_path)
|
||||||
|
follow_redirect!
|
||||||
|
expect(session[:user_id]).to be_nil
|
||||||
|
end
|
||||||
|
|
||||||
|
it "accetta anche GET /logout come fallback" do
|
||||||
|
login!
|
||||||
|
get "/logout"
|
||||||
|
expect(response).to redirect_to(public_pricing_path)
|
||||||
|
expect(session[:user_id]).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Analytics::PathNormalizer do
|
||||||
|
it "normalizza UUID e token" do
|
||||||
|
expect(described_class.normalize("/clubs/461d369a-104c-4d3c-8860-1518b4e95d35/billing"))
|
||||||
|
.to eq("/clubs/:id/billing")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "esclude admin, regia, live player e replay" do
|
||||||
|
expect(described_class.excluded?("/admin/sessions")).to be(true)
|
||||||
|
expect(described_class.excluded?("/regia/abc")).to be(true)
|
||||||
|
expect(described_class.excluded?("/live/461d369a-104c-4d3c-8860-1518b4e95d35")).to be(true)
|
||||||
|
expect(described_class.excluded?("/replay/461d369a-104c-4d3c-8860-1518b4e95d35")).to be(true)
|
||||||
|
expect(described_class.excluded?("/live")).to be(false)
|
||||||
|
expect(described_class.excluded?("/prezzi")).to be(false)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -29,6 +29,8 @@ CRON_BLOCK="${MARKER}
|
|||||||
30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1
|
30 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:cleanup_local >> ${LOG_FILE} 2>&1
|
||||||
0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
|
0 3 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:purge_expired >> ${LOG_FILE} 2>&1
|
||||||
0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
|
0 8 * * * mkdir -p ${LOG_DIR} && ${RUNNER} recordings:expiry_warnings >> ${LOG_FILE} 2>&1
|
||||||
|
15 * * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:aggregate >> ${LOG_DIR}/cron-analytics.log 2>&1
|
||||||
|
20 4 * * * mkdir -p ${LOG_DIR} && ${RUNNER} analytics:purge >> ${LOG_DIR}/cron-analytics.log 2>&1
|
||||||
*/10 * * * * mkdir -p ${LOG_DIR} && /bin/bash ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1
|
*/10 * * * * mkdir -p ${LOG_DIR} && /bin/bash ${SCAN_LOGS} >> ${OPS_LOG_FILE} 2>&1
|
||||||
"
|
"
|
||||||
|
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ android {
|
|||||||
applicationId = "com.matchlivetv.match_live_tv"
|
applicationId = "com.matchlivetv.match_live_tv"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 36
|
targetSdk = 36
|
||||||
versionCode = 32
|
versionCode = 33
|
||||||
versionName = "2.0.11-native"
|
versionName = "2.0.12-native"
|
||||||
|
|
||||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||||
?: "https://www.matchlivetv.it"
|
?: "https://www.matchlivetv.it"
|
||||||
|
|||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build AAB produzione firmato per Google Play Console.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
ANDROID_DIR="$ROOT/native/android"
|
||||||
|
API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}"
|
||||||
|
|
||||||
|
cd "$ANDROID_DIR"
|
||||||
|
chmod +x gradlew
|
||||||
|
./gradlew clean bundleProdRelease -PAPI_BASE_URL="$API_BASE_URL"
|
||||||
|
|
||||||
|
AAB="$ANDROID_DIR/app/build/outputs/bundle/prodRelease/app-prod-release.aab"
|
||||||
|
VERSION_NAME=$(grep -E '^\s*versionName\s*=' app/build.gradle.kts | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
VERSION_CODE=$(grep -E '^\s*versionCode\s*=' app/build.gradle.kts | head -1 | sed -E 's/.*=\s*([0-9]+).*/\1/')
|
||||||
|
DIST_DIR="$ANDROID_DIR/dist"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
DIST_AAB="$DIST_DIR/MatchLiveTV-${VERSION_NAME}-${VERSION_CODE}.aab"
|
||||||
|
cp -f "$AAB" "$DIST_AAB"
|
||||||
|
|
||||||
|
echo "AAB: $AAB"
|
||||||
|
echo "DIST: $DIST_AAB"
|
||||||
|
echo "API_BASE_URL=$API_BASE_URL"
|
||||||
|
ls -lh "$DIST_AAB"
|
||||||
@@ -9,5 +9,14 @@ chmod +x gradlew
|
|||||||
./gradlew assembleProdRelease -PAPI_BASE_URL="$API_BASE_URL"
|
./gradlew assembleProdRelease -PAPI_BASE_URL="$API_BASE_URL"
|
||||||
|
|
||||||
APK="$ANDROID_DIR/app/build/outputs/apk/prod/release/app-prod-release.apk"
|
APK="$ANDROID_DIR/app/build/outputs/apk/prod/release/app-prod-release.apk"
|
||||||
|
VERSION_NAME=$(grep -E '^\s*versionName\s*=' app/build.gradle.kts | head -1 | sed -E 's/.*"([^"]+)".*/\1/')
|
||||||
|
VERSION_CODE=$(grep -E '^\s*versionCode\s*=' app/build.gradle.kts | head -1 | sed -E 's/.*=\s*([0-9]+).*/\1/')
|
||||||
|
DIST_DIR="$ANDROID_DIR/dist"
|
||||||
|
mkdir -p "$DIST_DIR"
|
||||||
|
DIST_APK="$DIST_DIR/MatchLiveTV-${VERSION_NAME}-${VERSION_CODE}.apk"
|
||||||
|
cp -f "$APK" "$DIST_APK"
|
||||||
|
|
||||||
echo "APK: $APK"
|
echo "APK: $APK"
|
||||||
|
echo "DIST: $DIST_APK"
|
||||||
echo "API_BASE_URL=$API_BASE_URL"
|
echo "API_BASE_URL=$API_BASE_URL"
|
||||||
|
ls -lh "$DIST_APK"
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ if ssh "$SERVER" 'command -v rsync >/dev/null 2>&1'; then
|
|||||||
--exclude 'backend/tmp' \
|
--exclude 'backend/tmp' \
|
||||||
--exclude 'backend/.bundle' \
|
--exclude 'backend/.bundle' \
|
||||||
--exclude 'infra/.env' \
|
--exclude 'infra/.env' \
|
||||||
|
--exclude 'infra/slates/custom/' \
|
||||||
--exclude 'infra/garage/garage.prod.toml' \
|
--exclude 'infra/garage/garage.prod.toml' \
|
||||||
--exclude 'infra/garage/prod-credentials.env' \
|
--exclude 'infra/garage/prod-credentials.env' \
|
||||||
--exclude 'log/' \
|
--exclude 'log/' \
|
||||||
|
|||||||
Reference in New Issue
Block a user