Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35dfa923e3 | ||
|
|
9d8b35c06c | ||
|
|
873e0ea55c | ||
|
|
4573edfedc | ||
|
|
b301868774 | ||
|
|
b87a0f7bc0 | ||
|
|
70fccc493e | ||
|
|
406a90ae5b | ||
|
|
8e7bbdf8b2 | ||
|
|
b84321346f | ||
|
|
8a07c5d569 | ||
|
|
cc96c0396a | ||
|
|
79c48b226e | ||
|
|
c9b75065bc | ||
|
|
4ca9f3af8b | ||
|
|
f02782d0f0 | ||
|
|
fb82b74e92 | ||
|
|
60dfe7a75b | ||
|
|
a07f231417 | ||
|
|
43d5003de8 | ||
|
|
eadfc29da3 | ||
|
|
50d3e2cf16 | ||
|
|
55e71ca838 | ||
|
|
5b723bf844 |
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
description: Scalare soft limit ingest/autoscaler man mano che crescono i clienti
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Soft limit stream / autoscaler
|
||||||
|
|
||||||
|
Con la crescita del numero di clienti (e delle dirette concorrenti attese), **alzare i soft limit** prima di restare a corto di capacità.
|
||||||
|
|
||||||
|
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
|
||||||
|
|
||||||
|
| Parametro | Ruolo oggi (post load test 2026-08) |
|
||||||
|
|-----------|--------------------------------------|
|
||||||
|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
|
||||||
|
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
|
||||||
|
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
|
||||||
|
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
|
||||||
|
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
|
||||||
|
|
||||||
|
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
|
||||||
|
|
||||||
|
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class AnalyticsController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
@filters = {
|
||||||
|
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
||||||
|
to: parse_date(params[:to]) || Time.zone.today,
|
||||||
|
device: params[:device].presence
|
||||||
|
}
|
||||||
|
|
||||||
|
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
|
||||||
|
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||||
|
|
||||||
|
cell_scope = AnalyticsPageCell.where(day: @filters[:from]..@filters[:to])
|
||||||
|
cell_scope = cell_scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||||
|
|
||||||
|
clicks_by_path = cell_scope.group(:page_path).sum(:click_count)
|
||||||
|
moves_by_path = cell_scope.group(:page_path).sum(:move_count)
|
||||||
|
pageviews_by_path = scope.group(:page_path).sum(:pageview_count)
|
||||||
|
scroll_samples_by_path = scope.group(:page_path).sum(:scroll_samples)
|
||||||
|
scroll_sum_by_path = scope.group(:page_path).sum(:scroll_sum_pct)
|
||||||
|
max_scroll_by_path = scope.group(:page_path).maximum(:max_scroll_pct)
|
||||||
|
|
||||||
|
paths = (pageviews_by_path.keys + clicks_by_path.keys + moves_by_path.keys).uniq
|
||||||
|
@pages = paths.map do |path|
|
||||||
|
samples = scroll_samples_by_path[path].to_i
|
||||||
|
{
|
||||||
|
page_path: path,
|
||||||
|
pageviews: pageviews_by_path[path].to_i,
|
||||||
|
clicks: clicks_by_path[path].to_i,
|
||||||
|
moves: moves_by_path[path].to_i,
|
||||||
|
scroll_samples: samples,
|
||||||
|
scroll_sum: scroll_sum_by_path[path].to_i,
|
||||||
|
max_scroll: max_scroll_by_path[path].to_i
|
||||||
|
}
|
||||||
|
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
@page_path = params[:page_path].to_s
|
||||||
|
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
|
||||||
|
|
||||||
|
@filters = {
|
||||||
|
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
||||||
|
to: parse_date(params[:to]) || Time.zone.today,
|
||||||
|
device: params[:device].presence,
|
||||||
|
layer: params[:layer].to_s
|
||||||
|
}
|
||||||
|
|
||||||
|
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
||||||
|
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||||
|
|
||||||
|
@click_total = cells.sum(:click_count)
|
||||||
|
@move_total = cells.sum(:move_count)
|
||||||
|
@filters[:layer] =
|
||||||
|
if %w[move click].include?(@filters[:layer])
|
||||||
|
@filters[:layer]
|
||||||
|
elsif @move_total.positive?
|
||||||
|
"move"
|
||||||
|
else
|
||||||
|
"click"
|
||||||
|
end
|
||||||
|
|
||||||
|
counter = @filters[:layer] == "click" ? :click_count : :move_count
|
||||||
|
@cells = cells.group(:cell_x, :cell_y).sum(counter)
|
||||||
|
@max_weight = @cells.values.max.to_i
|
||||||
|
@total_points = @cells.values.sum
|
||||||
|
|
||||||
|
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
||||||
|
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||||
|
@pageviews = stats.sum(:pageview_count)
|
||||||
|
@scroll_samples = stats.sum(:scroll_samples)
|
||||||
|
@scroll_sum = stats.sum(:scroll_sum_pct)
|
||||||
|
@max_scroll = stats.maximum(:max_scroll_pct).to_i
|
||||||
|
@avg_scroll = @scroll_samples.positive? ? (@scroll_sum.to_f / @scroll_samples).round : 0
|
||||||
|
@grid = AnalyticsPageCell::GRID_SIZE
|
||||||
|
@snapshot = find_snapshot(@page_path, @filters[:device])
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse_date(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
Date.parse(value.to_s)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_snapshot(page_path, device)
|
||||||
|
scope = AnalyticsPageSnapshot.where(page_path: page_path)
|
||||||
|
if device.present? && AnalyticsEvent::DEVICES.include?(device)
|
||||||
|
snap = scope.find_by(device: device)
|
||||||
|
return snap if snap&.image&.attached?
|
||||||
|
end
|
||||||
|
|
||||||
|
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
module Admin
|
||||||
|
class AnnouncementsController < Admin::BaseController
|
||||||
|
before_action :set_announcement, only: %i[edit update destroy]
|
||||||
|
|
||||||
|
def index
|
||||||
|
@announcements = AppAnnouncement.newest
|
||||||
|
end
|
||||||
|
|
||||||
|
def new
|
||||||
|
@announcement = AppAnnouncement.new(
|
||||||
|
kind: "info",
|
||||||
|
severity: "info",
|
||||||
|
status: "draft",
|
||||||
|
show_on_android: true,
|
||||||
|
show_on_ios: true,
|
||||||
|
show_on_web_public: true,
|
||||||
|
show_on_web_private: true,
|
||||||
|
dismissible: true
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@announcement = AppAnnouncement.new(announcement_params)
|
||||||
|
@announcement.created_by_admin = current_admin_account
|
||||||
|
if @announcement.save
|
||||||
|
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @announcement.update(announcement_params)
|
||||||
|
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
@announcement.destroy!
|
||||||
|
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_announcement
|
||||||
|
@announcement = AppAnnouncement.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def announcement_params
|
||||||
|
params.require(:app_announcement).permit(
|
||||||
|
:kind, :severity, :status, :title, :body,
|
||||||
|
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
|
||||||
|
:show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
|
||||||
|
translations: {
|
||||||
|
en: AppAnnouncement::COPY_KEYS,
|
||||||
|
fr: AppAnnouncement::COPY_KEYS,
|
||||||
|
de: AppAnnouncement::COPY_KEYS,
|
||||||
|
es: AppAnnouncement::COPY_KEYS
|
||||||
|
}
|
||||||
|
).tap do |permitted|
|
||||||
|
%i[dismissible show_on_android show_on_ios show_on_web_public show_on_web_private].each do |key|
|
||||||
|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
|
||||||
|
end
|
||||||
|
%i[starts_at ends_at action_url action_label].each do |key|
|
||||||
|
permitted[key] = nil if permitted[key].blank?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
module Admin
|
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
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
module Api
|
||||||
|
module V1
|
||||||
|
class AnnouncementsController < BaseController
|
||||||
|
skip_before_action :authenticate_request!
|
||||||
|
|
||||||
|
def index
|
||||||
|
platform = params[:platform].to_s.presence
|
||||||
|
announcements = AppAnnouncement.for_platform(platform).newest
|
||||||
|
render json: announcements.map { |item| item.as_api_json(platform: platform) }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,7 @@ module Api
|
|||||||
module V1
|
module V1
|
||||||
class BaseController < ApplicationController
|
class BaseController < ApplicationController
|
||||||
rescue_from Teams::EntitlementError, with: :render_entitlement_error
|
rescue_from Teams::EntitlementError, with: :render_entitlement_error
|
||||||
|
rescue_from Streams::IngestUnavailableError, with: :render_ingest_unavailable
|
||||||
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
|
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
|
||||||
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
|
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
|
||||||
|
|
||||||
@@ -22,6 +23,13 @@ module Api
|
|||||||
}, status: :forbidden
|
}, status: :forbidden
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_ingest_unavailable(error)
|
||||||
|
render json: {
|
||||||
|
error: error.message,
|
||||||
|
error_code: error.code
|
||||||
|
}, status: :service_unavailable
|
||||||
|
end
|
||||||
|
|
||||||
def render_cover_upload_error(error)
|
def render_cover_upload_error(error)
|
||||||
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
|
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ module Api
|
|||||||
thermal_state: sanitized_thermal_state,
|
thermal_state: sanitized_thermal_state,
|
||||||
last_seen_at: Time.current
|
last_seen_at: Time.current
|
||||||
)
|
)
|
||||||
|
Sessions::ApplyClientInfo.call(@session, params[:client]) if params[:client].present?
|
||||||
sync_publisher_when_streaming!(params[:fps].to_f)
|
sync_publisher_when_streaming!(params[:fps].to_f)
|
||||||
SessionChannel.broadcast_message(@session, state.as_cable_payload)
|
SessionChannel.broadcast_message(@session, state.as_cable_payload)
|
||||||
head :no_content
|
head :no_content
|
||||||
@@ -180,7 +181,12 @@ module Api
|
|||||||
end
|
end
|
||||||
|
|
||||||
def session_params
|
def session_params
|
||||||
params.permit(:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel)
|
params.permit(
|
||||||
|
:platform, :privacy_status, :quality_preset, :target_bitrate, :target_fps, :youtube_channel,
|
||||||
|
client: %i[os client_os app_version app_build version build build_number
|
||||||
|
device_manufacturer manufacturer device_model model
|
||||||
|
os_version system_version carrier network_operator operator]
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def score_sync_params
|
def score_sync_params
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
module Public
|
||||||
|
class ContactsController < WebBaseController
|
||||||
|
def show
|
||||||
|
@inquiry = empty_inquiry
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
result = Contacts::SubmitInquiry.call(params: inquiry_params, ip: request.remote_ip)
|
||||||
|
|
||||||
|
if result.ok
|
||||||
|
redirect_to public_contatti_path, notice: t("flash.contacts.sent")
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
@inquiry = inquiry_params
|
||||||
|
flash.now[:alert] = t("flash.contacts.#{result.error}")
|
||||||
|
render :show, status: result.error == :throttled ? :too_many_requests : :unprocessable_entity
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def inquiry_params
|
||||||
|
{
|
||||||
|
name: params[:name].to_s,
|
||||||
|
email: params[:email].to_s,
|
||||||
|
club_name: params[:club_name].to_s,
|
||||||
|
topic: params[:topic].to_s,
|
||||||
|
message: params[:message].to_s,
|
||||||
|
website: params[:website].to_s,
|
||||||
|
accept_privacy: params[:accept_privacy].to_s
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def empty_inquiry
|
||||||
|
{
|
||||||
|
name: "",
|
||||||
|
email: "",
|
||||||
|
club_name: "",
|
||||||
|
topic: "commercial",
|
||||||
|
message: "",
|
||||||
|
website: "",
|
||||||
|
accept_privacy: ""
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
module Public
|
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)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ module Public
|
|||||||
|
|
||||||
protect_from_forgery with: :exception
|
protect_from_forgery with: :exception
|
||||||
before_action :set_site_locale
|
before_action :set_site_locale
|
||||||
|
before_action :load_site_announcements
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -40,5 +41,27 @@ module Public
|
|||||||
def logged_in?
|
def logged_in?
|
||||||
current_user.present?
|
current_user.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
PRIVATE_WEB_CONTROLLERS = %w[
|
||||||
|
accounts clubs teams club_recordings club_billing
|
||||||
|
club_matches matches team_roster_members
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def load_site_announcements
|
||||||
|
@site_announcements = if skip_site_announcements?
|
||||||
|
AppAnnouncement.none
|
||||||
|
else
|
||||||
|
channel = private_web_area? ? :web_private : :web_public
|
||||||
|
AppAnnouncement.for_channel(channel).newest
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def skip_site_announcements?
|
||||||
|
controller_name == "regia"
|
||||||
|
end
|
||||||
|
|
||||||
|
def private_web_area?
|
||||||
|
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ module Public
|
|||||||
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
|
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
|
||||||
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
|
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
|
||||||
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
|
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
|
||||||
|
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
|
||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
|
|||||||
@@ -30,6 +30,111 @@ 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_session_client_os_label(session)
|
||||||
|
case session.client_os.to_s
|
||||||
|
when "android" then "Android"
|
||||||
|
when "ios" then "iOS"
|
||||||
|
else I18n.t("admin.common.dash")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_session_client_summary(session)
|
||||||
|
parts = []
|
||||||
|
parts << admin_session_client_os_label(session) if session.client_os.present?
|
||||||
|
if session.app_version.present?
|
||||||
|
ver = session.app_version
|
||||||
|
ver = "#{ver} (#{session.app_build})" if session.app_build.present?
|
||||||
|
parts << "app #{ver}"
|
||||||
|
end
|
||||||
|
device = [session.device_manufacturer, session.device_model].compact_blank.join(" ")
|
||||||
|
parts << device if device.present?
|
||||||
|
parts << "OS #{session.os_version}" if session.os_version.present?
|
||||||
|
parts << session.carrier if session.carrier.present?
|
||||||
|
parts.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||||
|
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?
|
||||||
|
|
||||||
@@ -37,4 +142,31 @@ module AdminHelper
|
|||||||
rescue ArgumentError, TypeError
|
rescue ArgumentError, TypeError
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def announcement_status_badge(item)
|
||||||
|
case item.status
|
||||||
|
when "published" then item.severity == "critical" ? "live" : "ready"
|
||||||
|
when "archived" then "paused"
|
||||||
|
else "connecting"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def announcement_window_label(item)
|
||||||
|
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
|
||||||
|
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
|
||||||
|
if start_label && end_label
|
||||||
|
"#{start_label} → #{end_label}"
|
||||||
|
elsif start_label
|
||||||
|
I18n.t("admin.announcements.window.from", time: start_label)
|
||||||
|
elsif end_label
|
||||||
|
I18n.t("admin.announcements.window.until", time: end_label)
|
||||||
|
else
|
||||||
|
I18n.t("admin.announcements.window.always")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def announcement_channels_label(item)
|
||||||
|
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
||||||
|
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module LegalHelper
|
module LegalHelper
|
||||||
def legal_last_updated
|
def legal_last_updated
|
||||||
"3 giugno 2026"
|
"20 agosto 2026"
|
||||||
end
|
end
|
||||||
|
|
||||||
def cookie_policy_last_updated
|
def cookie_policy_last_updated
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
class AggregateJob < ApplicationJob
|
||||||
|
queue_as :default
|
||||||
|
|
||||||
|
def perform(purge: false)
|
||||||
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
lock_key = "analytics:aggregate:lock"
|
||||||
|
acquired = redis.set(lock_key, "1", nx: true, ex: 120)
|
||||||
|
return unless acquired
|
||||||
|
|
||||||
|
begin
|
||||||
|
service = Analytics::Aggregate.new
|
||||||
|
service.call
|
||||||
|
service.purge_old! if purge
|
||||||
|
ensure
|
||||||
|
redis.del(lock_key)
|
||||||
|
end
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[analytics] aggregate failed: #{e.class} #{e.message}")
|
||||||
|
raise
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
class ContactMailer < ApplicationMailer
|
||||||
|
def inquiry(name:, email:, club_name:, topic:, message:)
|
||||||
|
@name = name
|
||||||
|
@email = email
|
||||||
|
@club_name = club_name.presence
|
||||||
|
@topic = topic
|
||||||
|
@message = message
|
||||||
|
|
||||||
|
mail(
|
||||||
|
to: recipient_for(topic),
|
||||||
|
reply_to: email,
|
||||||
|
subject: t("mailers.contact.subject.#{topic}", name: name)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def recipient_for(topic)
|
||||||
|
if topic == "privacy"
|
||||||
|
MatchLiveTv.privacy_controller_email
|
||||||
|
else
|
||||||
|
MatchLiveTv.support_email
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,6 +2,7 @@ class AdminAccount < ApplicationRecord
|
|||||||
include PasswordComplexity
|
include PasswordComplexity
|
||||||
|
|
||||||
has_secure_password
|
has_secure_password
|
||||||
|
has_many :app_announcements, foreign_key: :created_by_admin_id, dependent: :nullify, inverse_of: :created_by_admin
|
||||||
|
|
||||||
validates :username, presence: true, uniqueness: true
|
validates :username, presence: true, uniqueness: true
|
||||||
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
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
class AppAnnouncement < ApplicationRecord
|
||||||
|
KINDS = %w[info maintenance update].freeze
|
||||||
|
SEVERITIES = %w[info warning critical].freeze
|
||||||
|
STATUSES = %w[draft published archived].freeze
|
||||||
|
CHANNELS = {
|
||||||
|
android: :show_on_android,
|
||||||
|
ios: :show_on_ios,
|
||||||
|
web_public: :show_on_web_public,
|
||||||
|
web_private: :show_on_web_private
|
||||||
|
}.freeze
|
||||||
|
COPY_KEYS = %w[title body action_url action_label].freeze
|
||||||
|
URL_KEYS = %w[action_url].freeze
|
||||||
|
TITLE_KEYS = %w[title].freeze
|
||||||
|
BODY_KEYS = %w[body].freeze
|
||||||
|
LABEL_KEYS = %w[action_label].freeze
|
||||||
|
LOCALE_FALLBACKS = {
|
||||||
|
"it" => %w[it],
|
||||||
|
"en" => %w[en it],
|
||||||
|
"fr" => %w[fr en it],
|
||||||
|
"de" => %w[de en it],
|
||||||
|
"es" => %w[es en it]
|
||||||
|
}.freeze
|
||||||
|
|
||||||
|
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
|
||||||
|
|
||||||
|
validates :kind, :severity, :status, :title, :body, presence: true
|
||||||
|
validates :kind, inclusion: { in: KINDS }
|
||||||
|
validates :severity, inclusion: { in: SEVERITIES }
|
||||||
|
validates :status, inclusion: { in: STATUSES }
|
||||||
|
validates :title, length: { maximum: 120 }, allow_blank: true
|
||||||
|
validates :body, length: { maximum: 2000 }, allow_blank: true
|
||||||
|
validates :action_label, length: { maximum: 40 }, allow_blank: true
|
||||||
|
URL_KEYS.each do |field|
|
||||||
|
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
|
||||||
|
end
|
||||||
|
validate :ends_at_after_starts_at
|
||||||
|
validate :at_least_one_channel
|
||||||
|
validate :translations_are_valid
|
||||||
|
before_validation :normalize_translations
|
||||||
|
|
||||||
|
scope :newest, -> { order(created_at: :desc) }
|
||||||
|
scope :published, -> { where(status: "published") }
|
||||||
|
scope :active_now, lambda {
|
||||||
|
now = Time.current
|
||||||
|
published
|
||||||
|
.where("starts_at IS NULL OR starts_at <= ?", now)
|
||||||
|
.where("ends_at IS NULL OR ends_at > ?", now)
|
||||||
|
}
|
||||||
|
|
||||||
|
def self.for_channel(channel)
|
||||||
|
column = CHANNELS[channel.to_sym]
|
||||||
|
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
|
||||||
|
|
||||||
|
active_now.where(column => true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.for_app
|
||||||
|
active_now.where("show_on_android = TRUE OR show_on_ios = TRUE")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.for_platform(platform)
|
||||||
|
case platform.to_s
|
||||||
|
when "android" then for_channel(:android)
|
||||||
|
when "ios" then for_channel(:ios)
|
||||||
|
else for_app
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def published?
|
||||||
|
status == "published"
|
||||||
|
end
|
||||||
|
|
||||||
|
def selected_channels
|
||||||
|
CHANNELS.each_with_object([]) do |(key, column), keys|
|
||||||
|
keys << key if public_send(column)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def copy_values_for(locale)
|
||||||
|
loc = locale.to_s
|
||||||
|
if default_locale?(loc)
|
||||||
|
COPY_KEYS.index_with { |key| public_send(key).to_s.presence }
|
||||||
|
else
|
||||||
|
translation_hash(loc)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def filled_locale_codes
|
||||||
|
LocaleResolver.available.select { |loc| locale_has_copy?(loc) }.map(&:to_s)
|
||||||
|
end
|
||||||
|
|
||||||
|
def as_api_json(platform: nil, locale: I18n.locale)
|
||||||
|
{
|
||||||
|
id: id,
|
||||||
|
kind: kind,
|
||||||
|
severity: severity,
|
||||||
|
title: copy_for("title", locale: locale),
|
||||||
|
body: copy_for("body", locale: locale),
|
||||||
|
dismissible: dismissible,
|
||||||
|
action_url: resolved_action_url(platform, locale: locale),
|
||||||
|
action_label: copy_for("action_label", locale: locale).presence,
|
||||||
|
starts_at: starts_at&.iso8601,
|
||||||
|
ends_at: ends_at&.iso8601
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def localized_title(locale: I18n.locale)
|
||||||
|
copy_for("title", locale: locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
def localized_body(locale: I18n.locale)
|
||||||
|
copy_for("body", locale: locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
def localized_action_label(locale: I18n.locale)
|
||||||
|
copy_for("action_label", locale: locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolved_action_url(platform, locale: I18n.locale)
|
||||||
|
url = copy_for("action_url", locale: locale)
|
||||||
|
return url if url.present?
|
||||||
|
return nil unless kind == "update"
|
||||||
|
|
||||||
|
case platform.to_s
|
||||||
|
when "android" then MatchLiveTv.play_store_url
|
||||||
|
when "ios" then MatchLiveTv.app_store_url
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def copy_for(field, locale: I18n.locale)
|
||||||
|
field = field.to_s
|
||||||
|
locale_chain(locale).each do |loc|
|
||||||
|
value = value_for(loc, field)
|
||||||
|
return value if value.present?
|
||||||
|
end
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def value_for(locale, key)
|
||||||
|
copy_values_for(locale)[key].presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def translation_hash(locale)
|
||||||
|
raw = translations.is_a?(Hash) ? translations : {}
|
||||||
|
values = raw.stringify_keys[locale.to_s]
|
||||||
|
return {} unless values.is_a?(Hash)
|
||||||
|
|
||||||
|
values.stringify_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def locale_chain(locale)
|
||||||
|
loc = (LocaleResolver.normalize(locale) || I18n.default_locale).to_s
|
||||||
|
LOCALE_FALLBACKS[loc] || [loc, I18n.default_locale.to_s].uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def locale_has_copy?(locale)
|
||||||
|
values = copy_values_for(locale)
|
||||||
|
values["title"].present? || values["body"].present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def default_locale?(locale)
|
||||||
|
locale.to_s == I18n.default_locale.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_translations
|
||||||
|
self.translations = self.class.sanitize_translations(translations)
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.sanitize_translations(value)
|
||||||
|
raw = coerce_translation_hash(value)
|
||||||
|
LocaleResolver.available.each_with_object({}) do |loc, acc|
|
||||||
|
next if loc.to_s == I18n.default_locale.to_s
|
||||||
|
|
||||||
|
payload = raw[loc.to_s]
|
||||||
|
next unless payload.is_a?(Hash)
|
||||||
|
|
||||||
|
cleaned = COPY_KEYS.each_with_object({}) do |key, fields|
|
||||||
|
text = payload[key].to_s.strip
|
||||||
|
fields[key] = text if text.present?
|
||||||
|
end
|
||||||
|
acc[loc.to_s] = cleaned if cleaned.any?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.coerce_translation_hash(value)
|
||||||
|
hash = if value.is_a?(ActionController::Parameters)
|
||||||
|
value.to_unsafe_h
|
||||||
|
elsif value.is_a?(Hash)
|
||||||
|
value
|
||||||
|
else
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
hash.deep_stringify_keys
|
||||||
|
end
|
||||||
|
private_class_method :coerce_translation_hash
|
||||||
|
|
||||||
|
def at_least_one_channel
|
||||||
|
return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private?
|
||||||
|
|
||||||
|
errors.add(:base, :no_channel)
|
||||||
|
end
|
||||||
|
|
||||||
|
def ends_at_after_starts_at
|
||||||
|
return if starts_at.blank? || ends_at.blank?
|
||||||
|
return if ends_at > starts_at
|
||||||
|
|
||||||
|
errors.add(:ends_at, :invalid)
|
||||||
|
end
|
||||||
|
|
||||||
|
def translations_are_valid
|
||||||
|
translation_entries.each do |locale, payload|
|
||||||
|
TITLE_KEYS.each do |key|
|
||||||
|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 120
|
||||||
|
end
|
||||||
|
BODY_KEYS.each do |key|
|
||||||
|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 2000
|
||||||
|
end
|
||||||
|
LABEL_KEYS.each do |key|
|
||||||
|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 40
|
||||||
|
end
|
||||||
|
URL_KEYS.each do |key|
|
||||||
|
url = payload[key].to_s
|
||||||
|
next if url.blank? || url.match?(/\Ahttps?:\/\/.+/i)
|
||||||
|
|
||||||
|
errors.add(:base, :translation_invalid_url, locale: locale)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def translation_entries
|
||||||
|
raw = translations.is_a?(Hash) ? translations : {}
|
||||||
|
raw.stringify_keys.filter_map do |locale, payload|
|
||||||
|
next unless payload.is_a?(Hash)
|
||||||
|
|
||||||
|
[locale, payload.stringify_keys]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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,107 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
class Aggregate
|
||||||
|
BATCH = 500
|
||||||
|
UPSERT_RETRIES = 3
|
||||||
|
|
||||||
|
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|
|
||||||
|
with_unique_retry do
|
||||||
|
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
|
||||||
|
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|
|
||||||
|
with_unique_retry do
|
||||||
|
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
|
||||||
|
|
||||||
|
def with_unique_retry
|
||||||
|
attempts = 0
|
||||||
|
begin
|
||||||
|
attempts += 1
|
||||||
|
yield
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
raise if attempts >= UPSERT_RETRIES
|
||||||
|
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
# 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?
|
||||||
|
if rows.any?
|
||||||
|
begin
|
||||||
|
Analytics::Aggregate.new.call
|
||||||
|
rescue ActiveRecord::RecordNotUnique => e
|
||||||
|
# Dopo i retry interni: non far fallire la richiesta analytics.
|
||||||
|
Rails.logger.warn("[analytics] aggregate race after retries, enqueue job: #{e.message}")
|
||||||
|
Analytics::AggregateJob.perform_later
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
Result.new(accepted: accepted, rejected: rejected + (@events.size - slice.size), rate_limited: false)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def rate_limited?
|
||||||
|
key = "analytics:ingest:#{@remote_ip}"
|
||||||
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
count = redis.incr(key)
|
||||||
|
redis.expire(key, 60) if count == 1
|
||||||
|
count > RATE_LIMIT_PER_MINUTE
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[analytics] rate limit unavailable: #{e.class}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def build_attrs(raw)
|
||||||
|
data = raw.is_a?(Hash) ? raw.with_indifferent_access : {}
|
||||||
|
event_type = data[:type].to_s.presence || data[:event_type].to_s
|
||||||
|
return nil unless AnalyticsEvent::EVENT_TYPES.include?(event_type)
|
||||||
|
|
||||||
|
device = data[:device].to_s
|
||||||
|
return nil unless AnalyticsEvent::DEVICES.include?(device)
|
||||||
|
|
||||||
|
page_path = PathNormalizer.normalize(data[:path] || data[:page_path])
|
||||||
|
return nil if PathNormalizer.excluded?(page_path)
|
||||||
|
return nil if page_path.length > 200
|
||||||
|
|
||||||
|
occurred_at = parse_time(data[:ts] || data[:occurred_at]) || Time.current
|
||||||
|
weight = data[:n].presence || data[:weight].presence || 1
|
||||||
|
weight = Integer(weight)
|
||||||
|
weight = 1 if weight < 1
|
||||||
|
weight = 500 if weight > 500
|
||||||
|
|
||||||
|
attrs = {
|
||||||
|
page_path: page_path,
|
||||||
|
device: device,
|
||||||
|
event_type: event_type,
|
||||||
|
occurred_at: occurred_at,
|
||||||
|
weight: weight,
|
||||||
|
x_pct: nil,
|
||||||
|
y_pct: nil,
|
||||||
|
scroll_pct: nil,
|
||||||
|
created_at: Time.current,
|
||||||
|
updated_at: Time.current
|
||||||
|
}
|
||||||
|
|
||||||
|
case event_type
|
||||||
|
when "click", "move"
|
||||||
|
x = clamp_pct(data[:x] || data[:x_pct])
|
||||||
|
y = clamp_pct(data[:y] || data[:y_pct])
|
||||||
|
return nil if x.nil? || y.nil?
|
||||||
|
|
||||||
|
attrs[:x_pct] = x
|
||||||
|
attrs[:y_pct] = y
|
||||||
|
when "scroll"
|
||||||
|
scroll = clamp_pct(data[:scroll] || data[:scroll_pct])
|
||||||
|
return nil if scroll.nil?
|
||||||
|
|
||||||
|
attrs[:scroll_pct] = scroll
|
||||||
|
when "pageview"
|
||||||
|
# no extra fields
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def clamp_pct(value)
|
||||||
|
return nil if value.nil?
|
||||||
|
|
||||||
|
n = Float(value)
|
||||||
|
return nil if n.nan? || n.infinite?
|
||||||
|
return nil if n.negative? || n > 100
|
||||||
|
|
||||||
|
n.round(2)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_time(value)
|
||||||
|
return value if value.is_a?(Time) || value.is_a?(ActiveSupport::TimeWithZone)
|
||||||
|
return Time.zone.at(value.to_i / 1000.0) if value.is_a?(Numeric) || value.to_s.match?(/\A\d{10,13}\z/)
|
||||||
|
|
||||||
|
Time.zone.parse(value.to_s)
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
class PathNormalizer
|
||||||
|
UUID_RE = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
|
||||||
|
NUMERIC_RE = /\A\d{6,}\z/
|
||||||
|
TOKENISH_RE = /\A[A-Za-z0-9_\-]{22,}\z/
|
||||||
|
|
||||||
|
EXCLUDED_PREFIXES = %w[/admin /regia /analytics /cable /api /webhooks /internal].freeze
|
||||||
|
|
||||||
|
class << self
|
||||||
|
def normalize(raw_path)
|
||||||
|
path = raw_path.to_s.split("?", 2).first.to_s
|
||||||
|
path = "/" if path.blank?
|
||||||
|
path = "/#{path}" unless path.start_with?("/")
|
||||||
|
path = path.gsub(%r{/{2,}}, "/").chomp("/")
|
||||||
|
path = "/" if path.blank?
|
||||||
|
|
||||||
|
parts = path.split("/").map do |segment|
|
||||||
|
next segment if segment.blank?
|
||||||
|
next ":id" if UUID_RE.match?(segment)
|
||||||
|
next ":id" if NUMERIC_RE.match?(segment)
|
||||||
|
next ":token" if TOKENISH_RE.match?(segment) && segment.match?(/[A-Z]/)
|
||||||
|
|
||||||
|
segment
|
||||||
|
end
|
||||||
|
normalized = parts.join("/")
|
||||||
|
normalized = "/" if normalized.blank?
|
||||||
|
normalized
|
||||||
|
end
|
||||||
|
|
||||||
|
def excluded?(raw_or_normalized)
|
||||||
|
path = normalize(raw_or_normalized)
|
||||||
|
return true if EXCLUDED_PREFIXES.any? { |p| path == p || path.start_with?("#{p}/") }
|
||||||
|
return true if path.match?(%r{\A/live/[^/]+})
|
||||||
|
return true if path.match?(%r{\A/replay/[^/]+})
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
class SnapshotIngest
|
||||||
|
MAX_BYTES = 1_800_000
|
||||||
|
MIN_REFRESH = 12.hours
|
||||||
|
RATE_LIMIT_PER_HOUR = 20
|
||||||
|
|
||||||
|
Result = Struct.new(:ok, :skipped, :rate_limited, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def initialize(path:, device:, width:, height:, image:, remote_ip:)
|
||||||
|
@path = path
|
||||||
|
@device = device.to_s
|
||||||
|
@width = width
|
||||||
|
@height = height
|
||||||
|
@image = image
|
||||||
|
@remote_ip = remote_ip.to_s.presence || "unknown"
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
return Result.new(ok: false, skipped: false, rate_limited: true) if rate_limited?
|
||||||
|
|
||||||
|
page_path = PathNormalizer.normalize(@path)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "path") if PathNormalizer.excluded?(page_path)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "device") unless AnalyticsEvent::DEVICES.include?(@device)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "image") unless valid_image?
|
||||||
|
|
||||||
|
width = Integer(@width)
|
||||||
|
height = Integer(@height)
|
||||||
|
return Result.new(ok: false, skipped: true, error: "size") if width < 200 || height < 200 || width > 6000 || height > 20000
|
||||||
|
|
||||||
|
snap = AnalyticsPageSnapshot.find_or_initialize_by(page_path: page_path, device: @device)
|
||||||
|
if snap.persisted? && snap.captured_at.present? && snap.captured_at > MIN_REFRESH.ago && snap.image.attached?
|
||||||
|
return Result.new(ok: true, skipped: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
snap.width = width
|
||||||
|
snap.height = height
|
||||||
|
snap.captured_at = Time.current
|
||||||
|
snap.save!
|
||||||
|
snap.image.purge if snap.image.attached?
|
||||||
|
snap.image.attach(
|
||||||
|
io: image_io,
|
||||||
|
filename: "analytics-#{Digest::SHA1.hexdigest(page_path)[0, 12]}-#{@device}.jpg",
|
||||||
|
content_type: "image/jpeg"
|
||||||
|
)
|
||||||
|
|
||||||
|
Result.new(ok: true, skipped: false)
|
||||||
|
rescue ArgumentError, TypeError, ActiveRecord::RecordInvalid => e
|
||||||
|
Result.new(ok: false, skipped: true, error: e.class.name)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def rate_limited?
|
||||||
|
key = "analytics:snapshot:#{@remote_ip}"
|
||||||
|
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
|
||||||
|
count = redis.incr(key)
|
||||||
|
redis.expire(key, 3600) if count == 1
|
||||||
|
count > RATE_LIMIT_PER_HOUR
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.warn("[analytics] snapshot rate limit unavailable: #{e.class}")
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_image?
|
||||||
|
return false if @image.blank?
|
||||||
|
return false unless @image.respond_to?(:tempfile) || @image.respond_to?(:read)
|
||||||
|
return false if @image.respond_to?(:size) && @image.size.to_i > MAX_BYTES
|
||||||
|
|
||||||
|
content_type = @image.content_type.to_s if @image.respond_to?(:content_type)
|
||||||
|
content_type.blank? || content_type.start_with?("image/")
|
||||||
|
end
|
||||||
|
|
||||||
|
def image_io
|
||||||
|
if @image.respond_to?(:tempfile)
|
||||||
|
@image.tempfile.rewind
|
||||||
|
@image.tempfile
|
||||||
|
else
|
||||||
|
StringIO.new(@image.read)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
module Contacts
|
||||||
|
class SubmitInquiry
|
||||||
|
TOPICS = %w[commercial support privacy].freeze
|
||||||
|
LIMIT = 5
|
||||||
|
WINDOW = 1.hour
|
||||||
|
NAME_MAX = 120
|
||||||
|
CLUB_MAX = 160
|
||||||
|
MESSAGE_MIN = 10
|
||||||
|
MESSAGE_MAX = 4_000
|
||||||
|
|
||||||
|
Result = Struct.new(:ok, :error, keyword_init: true)
|
||||||
|
|
||||||
|
def self.call(params:, ip:)
|
||||||
|
new(params: params, ip: ip).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(params:, ip:)
|
||||||
|
@params = params
|
||||||
|
@ip = ip.to_s.presence || "unknown"
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
if honeypot_filled?
|
||||||
|
record_attempt
|
||||||
|
return Result.new(ok: true, error: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Result.new(ok: false, error: :throttled) if throttled?
|
||||||
|
|
||||||
|
error = validate
|
||||||
|
return Result.new(ok: false, error: error) if error
|
||||||
|
|
||||||
|
record_attempt
|
||||||
|
deliver
|
||||||
|
Result.new(ok: true, error: nil)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def honeypot_filled?
|
||||||
|
@params[:website].to_s.strip.present?
|
||||||
|
end
|
||||||
|
|
||||||
|
def throttled?
|
||||||
|
current_count >= LIMIT
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate
|
||||||
|
return :privacy_required unless @params[:accept_privacy].to_s == "1"
|
||||||
|
return :invalid if name.blank? || name.length > NAME_MAX
|
||||||
|
return :invalid_email if email.blank? || email !~ URI::MailTo::EMAIL_REGEXP
|
||||||
|
return :invalid if club_name.length > CLUB_MAX
|
||||||
|
return :invalid unless TOPICS.include?(topic)
|
||||||
|
return :invalid_message if message.length < MESSAGE_MIN || message.length > MESSAGE_MAX
|
||||||
|
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def deliver
|
||||||
|
mail = ContactMailer.inquiry(
|
||||||
|
name: name,
|
||||||
|
email: email,
|
||||||
|
club_name: club_name,
|
||||||
|
topic: topic,
|
||||||
|
message: message
|
||||||
|
)
|
||||||
|
MatchLiveTv.deliver_mail(mail)
|
||||||
|
end
|
||||||
|
|
||||||
|
def record_attempt
|
||||||
|
Rails.cache.write(cache_key, current_count + 1, expires_in: WINDOW, raw: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
def current_count
|
||||||
|
Rails.cache.read(cache_key, raw: true).to_i
|
||||||
|
end
|
||||||
|
|
||||||
|
def cache_key
|
||||||
|
"contacts:inquiry:#{@ip}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def name
|
||||||
|
@params[:name].to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def email
|
||||||
|
@params[:email].to_s.strip.downcase
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_name
|
||||||
|
@params[:club_name].to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def topic
|
||||||
|
@params[:topic].to_s.strip
|
||||||
|
end
|
||||||
|
|
||||||
|
def message
|
||||||
|
@params[:message].to_s.strip
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -4,6 +4,9 @@ module Mediamtx
|
|||||||
class Client
|
class Client
|
||||||
class Error < StandardError; end
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
CREATE_PATH_RETRIES = -> { ENV.fetch("MEDIAMTX_CREATE_RETRIES", "5").to_i }
|
||||||
|
CREATE_PATH_RETRY_BASE_SECS = -> { ENV.fetch("MEDIAMTX_CREATE_RETRY_BASE_SECS", "0.4").to_f }
|
||||||
|
|
||||||
def self.for_session(session)
|
def self.for_session(session)
|
||||||
new(base_url: session.mediamtx_api_base_url)
|
new(base_url: session.mediamtx_api_base_url)
|
||||||
end
|
end
|
||||||
@@ -19,6 +22,19 @@ module Mediamtx
|
|||||||
|
|
||||||
attr_reader :base_url
|
attr_reader :base_url
|
||||||
|
|
||||||
|
# Health probe for CPX readiness (GET /v3/paths/list).
|
||||||
|
def reachable?(timeout: 2)
|
||||||
|
conn = Faraday.new(url: @base_url) do |f|
|
||||||
|
f.adapter Faraday.default_adapter
|
||||||
|
f.options.open_timeout = timeout
|
||||||
|
f.options.timeout = timeout
|
||||||
|
end
|
||||||
|
response = conn.get("/v3/paths/list")
|
||||||
|
response.success?
|
||||||
|
rescue Faraday::Error
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
def create_path(session)
|
def create_path(session)
|
||||||
path = session.mediamtx_path_name
|
path = session.mediamtx_path_name
|
||||||
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
|
||||||
@@ -30,7 +46,9 @@ module Mediamtx
|
|||||||
body[:alwaysAvailable] = true
|
body[:alwaysAvailable] = true
|
||||||
body[:alwaysAvailableFile] = slate_file_path(session)
|
body[:alwaysAvailableFile] = slate_file_path(session)
|
||||||
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
|
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
|
||||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
response = with_connection_retries("create_path #{path}") do
|
||||||
|
@conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||||
|
end
|
||||||
unless response.success?
|
unless response.success?
|
||||||
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
err = response.body.is_a?(Hash) ? response.body["error"] : response.body
|
||||||
raise Error, "MediaMTX path create failed: #{response.status} #{err}"
|
raise Error, "MediaMTX path create failed: #{response.status} #{err}"
|
||||||
@@ -161,6 +179,23 @@ module Mediamtx
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def with_connection_retries(label)
|
||||||
|
attempts = [CREATE_PATH_RETRIES.call, 1].max
|
||||||
|
base = CREATE_PATH_RETRY_BASE_SECS.call
|
||||||
|
try = 0
|
||||||
|
begin
|
||||||
|
try += 1
|
||||||
|
yield
|
||||||
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||||
|
raise if try >= attempts
|
||||||
|
|
||||||
|
sleep_secs = base * (2**(try - 1))
|
||||||
|
Rails.logger.warn("[Mediamtx::Client] #{label} retry #{try}/#{attempts} after #{e.class}: #{e.message} (sleep #{sleep_secs}s)")
|
||||||
|
sleep(sleep_secs)
|
||||||
|
retry
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def recording_body(session, enabled:)
|
def recording_body(session, enabled:)
|
||||||
ent = session.match.team.entitlements
|
ent = session.match.team.entitlements
|
||||||
can_record = ent.recording_enabled_for_mediamtx?
|
can_record = ent.recording_enabled_for_mediamtx?
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
# Normalizza e applica fingerprint del client (OS, app, device, operatore)
|
||||||
|
# sulla sessione, a create e/o a ogni telemetry.
|
||||||
|
class ApplyClientInfo
|
||||||
|
OS_VALUES = %w[android ios].freeze
|
||||||
|
MAX_LEN = 80
|
||||||
|
|
||||||
|
ATTRS = %i[
|
||||||
|
client_os app_version app_build device_manufacturer device_model os_version carrier
|
||||||
|
].freeze
|
||||||
|
|
||||||
|
def self.call(session, raw)
|
||||||
|
new(session, raw).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(session, raw)
|
||||||
|
@session = session
|
||||||
|
@raw = normalize_hash(raw)
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
attrs = extract_attrs
|
||||||
|
return @session if attrs.empty?
|
||||||
|
|
||||||
|
@session.assign_attributes(attrs)
|
||||||
|
@session.save! if @session.persisted? && @session.changed?
|
||||||
|
@session
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_hash(raw)
|
||||||
|
return {} if raw.blank?
|
||||||
|
|
||||||
|
data = raw.respond_to?(:to_unsafe_h) ? raw.to_unsafe_h : raw
|
||||||
|
data = data.to_h if data.respond_to?(:to_h)
|
||||||
|
data.with_indifferent_access
|
||||||
|
rescue StandardError
|
||||||
|
{}
|
||||||
|
end
|
||||||
|
|
||||||
|
def extract_attrs
|
||||||
|
attrs = {}
|
||||||
|
|
||||||
|
os = @raw[:os].presence || @raw[:client_os].presence
|
||||||
|
os = os.to_s.downcase.strip
|
||||||
|
attrs[:client_os] = os if OS_VALUES.include?(os)
|
||||||
|
|
||||||
|
{
|
||||||
|
app_version: %i[app_version version],
|
||||||
|
app_build: %i[app_build build build_number],
|
||||||
|
device_manufacturer: %i[device_manufacturer manufacturer],
|
||||||
|
device_model: %i[device_model model],
|
||||||
|
os_version: %i[os_version system_version],
|
||||||
|
carrier: %i[carrier network_operator operator]
|
||||||
|
}.each do |column, keys|
|
||||||
|
value = keys.map { |k| @raw[k] }.find(&:present?)
|
||||||
|
next if value.blank?
|
||||||
|
|
||||||
|
attrs[column] = truncate(value.to_s.strip)
|
||||||
|
end
|
||||||
|
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
def truncate(value)
|
||||||
|
value.bytesize <= MAX_LEN ? value : value.byteslice(0, MAX_LEN)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -26,6 +26,7 @@ module Sessions
|
|||||||
target_fps: @params[:target_fps] || 30,
|
target_fps: @params[:target_fps] || 30,
|
||||||
status: "idle"
|
status: "idle"
|
||||||
)
|
)
|
||||||
|
Sessions::ApplyClientInfo.call(session, @params[:client])
|
||||||
|
|
||||||
youtube_channel = nil
|
youtube_channel = nil
|
||||||
if session.platform == "youtube"
|
if session.platform == "youtube"
|
||||||
@@ -46,8 +47,11 @@ module Sessions
|
|||||||
{
|
{
|
||||||
created: true,
|
created: true,
|
||||||
platform: session.platform,
|
platform: session.platform,
|
||||||
stream_node: session.stream_node&.slug
|
stream_node: session.stream_node&.slug,
|
||||||
}
|
client_os: session.client_os,
|
||||||
|
app_version: session.app_version,
|
||||||
|
device_model: session.device_model
|
||||||
|
}.compact
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -58,6 +62,13 @@ module Sessions
|
|||||||
session
|
session
|
||||||
rescue Streams::NodeRegistry::NoCapacityError => e
|
rescue Streams::NodeRegistry::NoCapacityError => e
|
||||||
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
raise Teams::EntitlementError.new(e.message, code: "stream_capacity_exhausted")
|
||||||
|
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
||||||
|
raise Streams::IngestUnavailableError, "Ingest temporaneamente non disponibile (#{e.class})"
|
||||||
|
rescue Mediamtx::Client::Error => e
|
||||||
|
if e.message.to_s.match?(/failed to open|Connection refused|Timeout|timed out/i)
|
||||||
|
raise Streams::IngestUnavailableError, e.message
|
||||||
|
end
|
||||||
|
raise
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ module Streams
|
|||||||
|
|
||||||
def reconcile!
|
def reconcile!
|
||||||
actions = []
|
actions = []
|
||||||
|
actions.concat(promote_provisioning_nodes!)
|
||||||
|
actions.concat(reclaim_stuck_provisioning!)
|
||||||
m = self.class.metrics
|
m = self.class.metrics
|
||||||
|
|
||||||
if need_capacity?(m) && can_provision?(m)
|
if need_capacity?(m) && can_provision?(m)
|
||||||
@@ -170,6 +172,30 @@ module Streams
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def promote_provisioning_nodes!
|
||||||
|
actions = []
|
||||||
|
StreamNode.where(status: "provisioning").find_each do |node|
|
||||||
|
next unless Streams::NodeHealth.promote_if_healthy!(node)
|
||||||
|
|
||||||
|
actions << :"ready_#{node.slug}"
|
||||||
|
Rails.logger.info("[Streams::Autoscaler] promoted #{node.slug} to ready")
|
||||||
|
end
|
||||||
|
actions
|
||||||
|
end
|
||||||
|
|
||||||
|
def reclaim_stuck_provisioning!
|
||||||
|
actions = []
|
||||||
|
stuck_after = ENV.fetch("STREAM_NODE_PROVISIONING_STUCK_MINUTES", "15").to_i.minutes.ago
|
||||||
|
StreamNode.where(status: "provisioning").where("created_at < ?", stuck_after).find_each do |node|
|
||||||
|
@provisioner.decommission!(node)
|
||||||
|
actions << :"reclaim_#{node.slug}"
|
||||||
|
Rails.logger.warn("[Streams::Autoscaler] decommissioned stuck provisioning #{node.slug}")
|
||||||
|
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
|
||||||
|
Rails.logger.warn("[Streams::Autoscaler] reclaim #{node.slug}: #{e.message}")
|
||||||
|
end
|
||||||
|
actions
|
||||||
|
end
|
||||||
|
|
||||||
def need_capacity?(m)
|
def need_capacity?(m)
|
||||||
m[:free_slots] <= self.class.soft_free_slots
|
m[:free_slots] <= self.class.soft_free_slots
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
# Ingest MediaMTX non raggiungibile (es. CPX ancora in bootstrap). API → 503 retryable.
|
||||||
|
class IngestUnavailableError < StandardError
|
||||||
|
attr_reader :code
|
||||||
|
|
||||||
|
def initialize(message = "Ingest temporaneamente non disponibile", code: "stream_ingest_unavailable")
|
||||||
|
super(message)
|
||||||
|
@code = code
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Streams
|
||||||
|
# Probe reachability of MediaMTX API on a stream node (:9997).
|
||||||
|
class NodeHealth
|
||||||
|
def self.mediamtx_up?(node, timeout: 2)
|
||||||
|
new(node, timeout: timeout).mediamtx_up?
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.promote_if_healthy!(node, timeout: 2)
|
||||||
|
return false unless node.status == "provisioning"
|
||||||
|
return false unless mediamtx_up?(node, timeout: timeout)
|
||||||
|
|
||||||
|
node.update!(status: "ready", last_health_at: Time.current)
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(node, timeout: 2)
|
||||||
|
@node = node
|
||||||
|
@timeout = timeout
|
||||||
|
end
|
||||||
|
|
||||||
|
def mediamtx_up?
|
||||||
|
Mediamtx::Client.new(base_url: @node.api_base_url).reachable?(timeout: @timeout)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -80,11 +80,15 @@ module Streams
|
|||||||
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
|
urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
|
||||||
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
|
private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
|
||||||
|
|
||||||
StreamNode.create!(
|
# Simulated/lab che riusa MediaMTX home: subito ready. Cloud reale: provisioning
|
||||||
|
# finché :9997 risponde (evita allocate → Faraday Connection refused → 500).
|
||||||
|
initial_status = simulated ? "ready" : "provisioning"
|
||||||
|
|
||||||
|
node = StreamNode.create!(
|
||||||
slug: slug,
|
slug: slug,
|
||||||
hostname: hostname,
|
hostname: hostname,
|
||||||
role: role,
|
role: role,
|
||||||
status: "ready",
|
status: initial_status,
|
||||||
provider: provider_name_for(cloud, role: role),
|
provider: provider_name_for(cloud, role: role),
|
||||||
provider_instance_id: instance.id,
|
provider_instance_id: instance.id,
|
||||||
rtmp_base_url: urls.fetch(:rtmp_base_url),
|
rtmp_base_url: urls.fetch(:rtmp_base_url),
|
||||||
@@ -102,6 +106,34 @@ module Streams
|
|||||||
"cloud_raw" => instance.raw
|
"cloud_raw" => instance.raw
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
wait_until_mediamtx_ready!(node) unless simulated
|
||||||
|
node.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
def wait_until_mediamtx_ready!(node, timeout: nil, interval: nil)
|
||||||
|
timeout ||= ENV.fetch("STREAM_NODE_READY_TIMEOUT_SECS", "180").to_i
|
||||||
|
interval ||= ENV.fetch("STREAM_NODE_READY_POLL_SECS", "3").to_f
|
||||||
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
||||||
|
|
||||||
|
loop do
|
||||||
|
if Streams::NodeHealth.promote_if_healthy!(node)
|
||||||
|
Rails.logger.info("[Streams::NodeProvisioner] #{node.slug} MediaMTX ready")
|
||||||
|
return node
|
||||||
|
end
|
||||||
|
|
||||||
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
||||||
|
if remaining <= 0
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Streams::NodeProvisioner] #{node.slug} ancora provisioning dopo #{timeout}s " \
|
||||||
|
"(api=#{node.api_base_url}); autoscaler continuerà a promuovere"
|
||||||
|
)
|
||||||
|
return node
|
||||||
|
end
|
||||||
|
|
||||||
|
sleep([interval, remaining].min)
|
||||||
|
node.reload
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
|
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.analytics.index.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel admin-sessions-filters">
|
||||||
|
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.from") %></span>
|
||||||
|
<%= date_field_tag :from, @filters[:from] %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.to") %></span>
|
||||||
|
<%= date_field_tag :to, @filters[:to] %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.device") %></span>
|
||||||
|
<%= select_tag :device,
|
||||||
|
options_for_select(
|
||||||
|
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
|
||||||
|
@filters[:device]
|
||||||
|
) %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.analytics.filters.reset"), admin_analytics_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<% if @pages.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.analytics.index.table.path") %></th>
|
||||||
|
<th><%= t("admin.analytics.index.table.pageviews") %></th>
|
||||||
|
<th><%= t("admin.analytics.index.table.moves") %></th>
|
||||||
|
<th><%= t("admin.analytics.index.table.clicks") %></th>
|
||||||
|
<th><%= t("admin.analytics.index.table.avg_scroll") %></th>
|
||||||
|
<th><%= t("admin.analytics.index.table.max_scroll") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @pages.each do |row| %>
|
||||||
|
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
|
||||||
|
<tr>
|
||||||
|
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
|
||||||
|
<td><%= row[:pageviews] %></td>
|
||||||
|
<td><%= row[:moves] %></td>
|
||||||
|
<td><%= row[:clicks] %></td>
|
||||||
|
<td class="muted"><%= avg %>%</td>
|
||||||
|
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||||
|
<td>
|
||||||
|
<%= link_to t("admin.analytics.index.heatmap"),
|
||||||
|
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.analytics.index.none") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head admin-session-head">
|
||||||
|
<div>
|
||||||
|
<p class="muted admin-page-sub">
|
||||||
|
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
||||||
|
</p>
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
||||||
|
<p><code class="admin-mono"><%= @page_path %></code></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel admin-sessions-filters">
|
||||||
|
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
|
<%= hidden_field_tag :page_path, @page_path %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.from") %></span>
|
||||||
|
<%= date_field_tag :from, @filters[:from] %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.to") %></span>
|
||||||
|
<%= date_field_tag :to, @filters[:to] %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.device") %></span>
|
||||||
|
<%= select_tag :device,
|
||||||
|
options_for_select(
|
||||||
|
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
|
||||||
|
@filters[:device]
|
||||||
|
) %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.layer") %></span>
|
||||||
|
<%= select_tag :layer,
|
||||||
|
options_for_select(
|
||||||
|
[
|
||||||
|
[t("admin.analytics.filters.layer_move"), "move"],
|
||||||
|
[t("admin.analytics.filters.layer_click"), "click"]
|
||||||
|
],
|
||||||
|
@filters[:layer]
|
||||||
|
) %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-session-grid">
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.analytics.show.summary") %></h3>
|
||||||
|
<dl class="admin-kv">
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.analytics.index.table.pageviews") %></dt>
|
||||||
|
<dd><%= @pageviews %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.analytics.index.table.moves") %></dt>
|
||||||
|
<dd><%= @move_total %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.analytics.index.table.clicks") %></dt>
|
||||||
|
<dd><%= @click_total %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.analytics.index.table.avg_scroll") %></dt>
|
||||||
|
<dd><%= @avg_scroll %>%</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.analytics.index.table.max_scroll") %></dt>
|
||||||
|
<dd><%= @max_scroll %>%</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="grid-column: span 2;">
|
||||||
|
<h3><%= t("admin.analytics.show.scroll_depth") %></h3>
|
||||||
|
<div class="admin-scroll-bar" aria-hidden="true">
|
||||||
|
<div class="admin-scroll-bar__avg" style="width: <%= @avg_scroll %>%"></div>
|
||||||
|
<div class="admin-scroll-bar__max" style="left: <%= @max_scroll %>%"></div>
|
||||||
|
</div>
|
||||||
|
<p class="muted admin-table-sub">
|
||||||
|
<%= t("admin.analytics.show.scroll_hint", avg: @avg_scroll, max: @max_scroll) %>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3>
|
||||||
|
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
||||||
|
</h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
|
||||||
|
|
||||||
|
<% if @cells.any? %>
|
||||||
|
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||||
|
<% if @snapshot&.image&.attached? %>
|
||||||
|
<div
|
||||||
|
class="admin-heatmap-scaler"
|
||||||
|
id="admin-heatmap-scaler"
|
||||||
|
data-design-width="<%= [@snapshot.width, 1].max %>"
|
||||||
|
data-design-height="<%= [@snapshot.height, 1].max %>"
|
||||||
|
>
|
||||||
|
<%= image_tag url_for(@snapshot.image),
|
||||||
|
class: "admin-heatmap-shot",
|
||||||
|
id: "admin-heatmap-shot",
|
||||||
|
alt: t("admin.analytics.show.preview_title") %>
|
||||||
|
<canvas
|
||||||
|
id="admin-heatmap"
|
||||||
|
class="admin-heatmap-overlay"
|
||||||
|
data-grid="<%= @grid %>"
|
||||||
|
data-max="<%= [@max_weight, 1].max %>"
|
||||||
|
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||||
|
></canvas>
|
||||||
|
</div>
|
||||||
|
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
||||||
|
<%= t("admin.analytics.show.snapshot_meta",
|
||||||
|
device: @snapshot.device,
|
||||||
|
at: l(@snapshot.captured_at, format: :short)) %>
|
||||||
|
</p>
|
||||||
|
<% else %>
|
||||||
|
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||||
|
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
|
||||||
|
<canvas
|
||||||
|
id="admin-heatmap"
|
||||||
|
class="admin-heatmap-overlay"
|
||||||
|
data-grid="<%= @grid %>"
|
||||||
|
data-max="<%= [@max_weight, 1].max %>"
|
||||||
|
data-cells="<%= @cells.map { |(x, y), c| { x: x, y: y, c: c } }.to_json %>"
|
||||||
|
></canvas>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<script src="/admin-analytics-heatmap.js?v=4" defer></script>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.analytics.show.no_points") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<%= form_with model: announcement, url: (announcement.persisted? ? admin_announcement_path(announcement) : admin_announcements_path),
|
||||||
|
method: (announcement.persisted? ? :patch : :post), local: true, class: "admin-form" do |f| %>
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<div>
|
||||||
|
<%= f.label :kind, t("admin.announcements.form.kind") %>
|
||||||
|
<%= f.select :kind, AppAnnouncement::KINDS.map { |k| [t("admin.announcements.kind.#{k}"), k] } %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :severity, t("admin.announcements.form.severity") %>
|
||||||
|
<%= f.select :severity, AppAnnouncement::SEVERITIES.map { |k| [t("admin.announcements.severity.#{k}"), k] } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<%= f.label :status, t("admin.announcements.form.status") %>
|
||||||
|
<%= f.select :status, AppAnnouncement::STATUSES.map { |k| [t("admin.announcements.status.#{k}"), k] } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="admin-channels">
|
||||||
|
<legend><%= t("admin.announcements.form.channels") %></legend>
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= f.check_box :show_on_android %>
|
||||||
|
<span><%= t("admin.announcements.channels.android") %></span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= f.check_box :show_on_ios %>
|
||||||
|
<span><%= t("admin.announcements.channels.ios") %></span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= f.check_box :show_on_web_public %>
|
||||||
|
<span><%= t("admin.announcements.channels.web_public") %></span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= f.check_box :show_on_web_private %>
|
||||||
|
<span><%= t("admin.announcements.channels.web_private") %></span>
|
||||||
|
</label>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="admin-channels">
|
||||||
|
<legend><%= t("admin.announcements.form.locales_legend") %></legend>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.locales_hint") %></p>
|
||||||
|
<div class="admin-locale-tabs" role="tablist">
|
||||||
|
<% language_options.each do |opt| %>
|
||||||
|
<button type="button"
|
||||||
|
class="admin-locale-tab<%= " is-active" if opt[:code].to_s == I18n.default_locale.to_s %>"
|
||||||
|
data-locale-tab="<%= opt[:code] %>"
|
||||||
|
role="tab"
|
||||||
|
aria-selected="<%= opt[:code].to_s == I18n.default_locale.to_s %>">
|
||||||
|
<span aria-hidden="true"><%= opt[:flag] %></span>
|
||||||
|
<%= opt[:native] %>
|
||||||
|
<% if opt[:code].to_s == I18n.default_locale.to_s %>
|
||||||
|
<span class="muted">(<%= t("admin.announcements.form.locale_required") %>)</span>
|
||||||
|
<% elsif announcement.filled_locale_codes.include?(opt[:code].to_s) %>
|
||||||
|
<span class="muted">✓</span>
|
||||||
|
<% end %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% language_options.each do |opt| %>
|
||||||
|
<div class="admin-locale-panel"
|
||||||
|
data-locale-panel="<%= opt[:code] %>"
|
||||||
|
role="tabpanel"
|
||||||
|
<%= "hidden" unless opt[:code].to_s == I18n.default_locale.to_s %>>
|
||||||
|
<%= render "locale_copy", f: f, announcement: announcement, locale: opt[:code] %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<div>
|
||||||
|
<%= f.label :starts_at, t("admin.announcements.form.starts_at") %>
|
||||||
|
<%= f.datetime_field :starts_at, value: announcement.starts_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<%= f.label :ends_at, t("admin.announcements.form.ends_at") %>
|
||||||
|
<%= f.datetime_field :ends_at, value: announcement.ends_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
|
||||||
|
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= f.check_box :dismissible %>
|
||||||
|
<span><%= t("admin.announcements.form.dismissible") %></span>
|
||||||
|
</label>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.dismissible_hint") %></p>
|
||||||
|
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<%= f.submit (announcement.persisted? ? t("admin.announcements.form.submit_update") : t("admin.announcements.form.submit_create")), class: "admin-btn admin-btn--primary" %>
|
||||||
|
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var tabs = document.querySelectorAll("[data-locale-tab]");
|
||||||
|
if (!tabs.length) return;
|
||||||
|
tabs.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var loc = btn.getAttribute("data-locale-tab");
|
||||||
|
tabs.forEach(function (other) {
|
||||||
|
var on = other === btn;
|
||||||
|
other.classList.toggle("is-active", on);
|
||||||
|
other.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-locale-panel]").forEach(function (panel) {
|
||||||
|
panel.hidden = panel.getAttribute("data-locale-panel") !== loc;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<% it = locale.to_s == I18n.default_locale.to_s %>
|
||||||
|
<% values = announcement.copy_values_for(locale) %>
|
||||||
|
<% name_base = "app_announcement[translations][#{locale}]" %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% if it %>
|
||||||
|
<%= f.label :title, t("admin.announcements.form.title") %>
|
||||||
|
<%= f.text_field :title, required: true, maxlength: 120, id: "ann_#{locale}_title" %>
|
||||||
|
<% else %>
|
||||||
|
<label for="ann_<%= locale %>_title"><%= t("admin.announcements.form.title") %></label>
|
||||||
|
<%= text_field_tag "#{name_base}[title]", values["title"], maxlength: 120, id: "ann_#{locale}_title" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% if it %>
|
||||||
|
<%= f.label :body, t("admin.announcements.form.body") %>
|
||||||
|
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
|
||||||
|
<% else %>
|
||||||
|
<label for="ann_<%= locale %>_body"><%= t("admin.announcements.form.body") %></label>
|
||||||
|
<%= text_area_tag "#{name_base}[body]", values["body"], rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% if it %>
|
||||||
|
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
|
||||||
|
<%= f.url_field :action_url, placeholder: "https://", id: "ann_#{locale}_action_url" %>
|
||||||
|
<% else %>
|
||||||
|
<label for="ann_<%= locale %>_action_url"><%= t("admin.announcements.form.action_url") %></label>
|
||||||
|
<%= url_field_tag "#{name_base}[action_url]", values["action_url"], placeholder: "https://", id: "ann_#{locale}_action_url" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% if it %>
|
||||||
|
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
|
||||||
|
<%= f.text_field :action_label, maxlength: 40, id: "ann_#{locale}_action_label" %>
|
||||||
|
<% else %>
|
||||||
|
<label for="ann_<%= locale %>_action_label"><%= t("admin.announcements.form.action_label") %></label>
|
||||||
|
<%= text_field_tag "#{name_base}[action_label]", values["action_label"], maxlength: 40, id: "ann_#{locale}_action_label" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.announcements.edit.title") %></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<%= render "form", announcement: @announcement %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.announcements.index.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= t("admin.announcements.index.lead") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-bottom:1.5rem">
|
||||||
|
<div class="admin-toolbar">
|
||||||
|
<%= link_to t("admin.announcements.index.new"), new_admin_announcement_path, class: "admin-btn admin-btn--primary" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2><%= t("admin.announcements.index.table_title") %></h2>
|
||||||
|
<% if @announcements.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.announcements.table.status") %></th>
|
||||||
|
<th><%= t("admin.announcements.table.kind") %></th>
|
||||||
|
<th><%= t("admin.announcements.table.title") %></th>
|
||||||
|
<th><%= t("admin.announcements.table.channels") %></th>
|
||||||
|
<th><%= t("admin.announcements.table.window") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @announcements.each do |item| %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<span class="badge badge--<%= announcement_status_badge(item) %>"><%= t("admin.announcements.status.#{item.status}") %></span>
|
||||||
|
</td>
|
||||||
|
<td class="muted"><%= t("admin.announcements.kind.#{item.kind}") %></td>
|
||||||
|
<td>
|
||||||
|
<strong><%= item.title %></strong>
|
||||||
|
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(item.body, length: 90) %></div>
|
||||||
|
<div class="muted" style="font-size:0.8rem;margin-top:0.2rem"><%= item.filled_locale_codes.map(&:upcase).join(" · ") %></div>
|
||||||
|
</td>
|
||||||
|
<td class="muted"><%= announcement_channels_label(item) %></td>
|
||||||
|
<td class="muted"><%= announcement_window_label(item) %></td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
<%= link_to t("admin.announcements.actions.edit"), edit_admin_announcement_path(item), class: "admin-btn admin-btn--sm" %>
|
||||||
|
<%= button_to t("admin.announcements.actions.delete"), admin_announcement_path(item), method: :delete,
|
||||||
|
class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||||
|
form: { data: { confirm: t("admin.announcements.actions.delete_confirm") } } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.announcements.index.none") %></p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.announcements.new.title") %></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<%= render "form", announcement: @announcement %>
|
||||||
|
</div>
|
||||||
@@ -107,6 +107,8 @@
|
|||||||
<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.client") %></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 +119,16 @@
|
|||||||
<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 class="muted"><%= admin_session_client_summary(s) %></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,133 @@
|
|||||||
<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.client") %></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 class="muted admin-table-sub"><%= admin_session_client_summary(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,279 @@
|
|||||||
<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.client_os") %></dt>
|
||||||
|
<dd><%= admin_session_client_os_label(@session) %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.sessions.show.fields.app_version") %></dt>
|
||||||
|
<dd>
|
||||||
|
<% if @session.app_version.present? %>
|
||||||
|
<%= @session.app_version %>
|
||||||
|
<% if @session.app_build.present? %>
|
||||||
|
<span class="muted">(<%= @session.app_build %>)</span>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<%= t("admin.common.dash") %>
|
||||||
|
<% end %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.sessions.show.fields.device") %></dt>
|
||||||
|
<dd>
|
||||||
|
<% device = [@session.device_manufacturer, @session.device_model].compact_blank.join(" ") %>
|
||||||
|
<%= device.presence || t("admin.common.dash") %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.sessions.show.fields.os_version") %></dt>
|
||||||
|
<dd><%= @session.os_version.presence || t("admin.common.dash") %></dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt><%= t("admin.sessions.show.fields.carrier") %></dt>
|
||||||
|
<dd><%= @session.carrier.presence || t("admin.common.dash") %></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>
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<p><strong><%= t("mailers.contact.heading") %></strong></p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
<%= t("mailers.contact.name") %> <%= @name %><br>
|
||||||
|
<%= t("mailers.contact.email") %> <%= @email %><br>
|
||||||
|
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %><br>
|
||||||
|
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p><%= t("mailers.contact.message") %></p>
|
||||||
|
<p style="white-space:pre-wrap"><%= @message %></p>
|
||||||
|
|
||||||
|
<p style="color:#666;font-size:12px"><%= t("mailers.contact.footer") %></p>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<%= t("mailers.contact.heading") %>
|
||||||
|
|
||||||
|
<%= t("mailers.contact.name") %> <%= @name %>
|
||||||
|
<%= t("mailers.contact.email") %> <%= @email %>
|
||||||
|
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %>
|
||||||
|
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
|
||||||
|
|
||||||
|
<%= t("mailers.contact.message") %>
|
||||||
|
<%= @message %>
|
||||||
|
|
||||||
|
<%= t("mailers.contact.footer") %>
|
||||||
@@ -4,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=4">
|
<%= 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 %>
|
||||||
@@ -25,10 +26,12 @@
|
|||||||
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
|
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
|
||||||
<%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
|
<%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<%= link_to t("admin.layout.nav.announcements"), admin_announcements_path, class: ("active" if controller_name == "announcements") %>
|
||||||
<%= link_to t("admin.layout.nav.clubs"), admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
|
<%= link_to t("admin.layout.nav.clubs"), admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
|
||||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to t("admin.layout.nav.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,14 +5,16 @@
|
|||||||
<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">
|
||||||
<link rel="stylesheet" href="/marketing.css?v=64">
|
<link rel="stylesheet" href="/marketing.css?v=76">
|
||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
<%= render "shared/cookie_banner" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||||
|
<%= render "shared/site_announcements" %>
|
||||||
<main>
|
<main>
|
||||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||||
@@ -24,6 +26,7 @@
|
|||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<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,21 +4,24 @@
|
|||||||
<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=64">
|
<link rel="stylesheet" href="/marketing.css?v=76">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||||
<%= render "shared/cookie_banner" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
<%= render "shared/marketing_nav" %>
|
<%= render "shared/marketing_nav" %>
|
||||||
|
<%= render "shared/site_announcements" %>
|
||||||
<main class="live-main">
|
<main class="live-main">
|
||||||
<%= yield %>
|
<%= yield %>
|
||||||
</main>
|
</main>
|
||||||
<%= 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>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<% content_for :title, t("club.edit.title", name: @club.name) %>
|
<% content_for :title, t("club.edit.title", name: @club.name) %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
<div class="wrap wrap--portal" style="padding-top:20px">
|
||||||
<h1><%= t("club.edit.heading") %></h1>
|
<h1><%= t("club.edit.heading") %></h1>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
|
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<% content_for :title, t("pages.contacts.meta_title") %>
|
||||||
|
<% content_for :meta_description, t("pages.contacts.meta_description") %>
|
||||||
|
<% content_for :canonical_url, seo_absolute_url(public_contatti_path) %>
|
||||||
|
|
||||||
|
<article class="contacts-page">
|
||||||
|
<header class="features-hero contacts-hero">
|
||||||
|
<div class="wrap">
|
||||||
|
<p class="features-hero__eyebrow"><%= t("pages.contacts.eyebrow") %></p>
|
||||||
|
<h1><%= t("pages.contacts.title") %></h1>
|
||||||
|
<p class="features-hero__lead"><%= t("pages.contacts.lead") %></p>
|
||||||
|
<p class="contacts-hero__meta"><%= t("pages.contacts.response_time") %></p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="section wrap" aria-label="<%= t("pages.contacts.title") %>">
|
||||||
|
<div class="contacts-channels">
|
||||||
|
<article class="feature-card">
|
||||||
|
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-briefcase"></i></span>
|
||||||
|
<h2><%= t("pages.contacts.commercial_title") %></h2>
|
||||||
|
<p><%= t("pages.contacts.commercial_body") %></p>
|
||||||
|
<p class="contacts-channels__email">
|
||||||
|
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="feature-card">
|
||||||
|
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-life-ring"></i></span>
|
||||||
|
<h2><%= t("pages.contacts.support_title") %></h2>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"pages.contacts.support_body_html",
|
||||||
|
faq_link: link_to(t("pages.contacts.faq_link"), public_faq_path),
|
||||||
|
support_link: link_to(t("pages.contacts.support_link"), public_support_path)
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
<p class="contacts-channels__email">
|
||||||
|
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="feature-card">
|
||||||
|
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-shield-halved"></i></span>
|
||||||
|
<h2><%= t("pages.contacts.privacy_title") %></h2>
|
||||||
|
<p><%= t("pages.contacts.privacy_body") %></p>
|
||||||
|
<p class="contacts-channels__email">
|
||||||
|
<%= mail_to MatchLiveTv.privacy_controller_email, MatchLiveTv.privacy_controller_email %>
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap" aria-labelledby="contacts-company-title">
|
||||||
|
<div class="features-panel contacts-company">
|
||||||
|
<h2 id="contacts-company-title"><%= t("pages.contacts.company_title") %></h2>
|
||||||
|
<p class="features-panel__lead"><%= t("pages.contacts.company_lead") %></p>
|
||||||
|
<ul class="contacts-company__details">
|
||||||
|
<li><strong><%= MatchLiveTv.privacy_controller_name %></strong></li>
|
||||||
|
<li><%= t("pages.contacts.label_address") %>: <%= MatchLiveTv.privacy_controller_address %></li>
|
||||||
|
<% if MatchLiveTv.privacy_controller_vat.present? %>
|
||||||
|
<li><%= t("pages.contacts.label_vat") %>: <%= MatchLiveTv.privacy_controller_vat %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap" aria-labelledby="contacts-social-title">
|
||||||
|
<div class="features-panel contacts-social">
|
||||||
|
<h2 id="contacts-social-title"><%= t("pages.contacts.social_title") %></h2>
|
||||||
|
<%= render "shared/social_links", modifier: "panel" %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap" aria-labelledby="contacts-form-title">
|
||||||
|
<div class="contacts-form-panel">
|
||||||
|
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
|
||||||
|
<p class="contacts-form-panel__lead"><%= t("pages.contacts.form_lead") %></p>
|
||||||
|
|
||||||
|
<%= form_with url: public_contatti_path, method: :post, local: true, class: "contacts-form" do %>
|
||||||
|
<div class="contacts-hp" aria-hidden="true">
|
||||||
|
<label for="website"><%= t("pages.contacts.honeypot_label") %></label>
|
||||||
|
<%= text_field_tag :website, @inquiry[:website], tabindex: -1, autocomplete: "off" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%= label_tag :name, t("pages.contacts.name_label") %>
|
||||||
|
<%= text_field_tag :name, @inquiry[:name], required: true, maxlength: 120, autocomplete: "name" %>
|
||||||
|
|
||||||
|
<%= label_tag :email, t("pages.contacts.email_label") %>
|
||||||
|
<%= email_field_tag :email, @inquiry[:email], required: true, autocomplete: "email" %>
|
||||||
|
|
||||||
|
<%= label_tag :club_name, t("pages.contacts.club_label") %>
|
||||||
|
<%= text_field_tag :club_name, @inquiry[:club_name], maxlength: 160, autocomplete: "organization" %>
|
||||||
|
|
||||||
|
<%= label_tag :topic, t("pages.contacts.topic_label") %>
|
||||||
|
<%= select_tag :topic, options_for_select(
|
||||||
|
[
|
||||||
|
[t("pages.contacts.topic_commercial"), "commercial"],
|
||||||
|
[t("pages.contacts.topic_support"), "support"],
|
||||||
|
[t("pages.contacts.topic_privacy"), "privacy"]
|
||||||
|
],
|
||||||
|
@inquiry[:topic]
|
||||||
|
), required: true %>
|
||||||
|
|
||||||
|
<%= label_tag :message, t("pages.contacts.message_label") %>
|
||||||
|
<%= text_area_tag :message, @inquiry[:message], required: true, rows: 6, maxlength: 4000 %>
|
||||||
|
|
||||||
|
<label class="legal-accept">
|
||||||
|
<%= check_box_tag :accept_privacy, "1", @inquiry[:accept_privacy] == "1", required: true %>
|
||||||
|
<span class="legal-accept-text">
|
||||||
|
<%= raw t(
|
||||||
|
"pages.contacts.privacy_accept_html",
|
||||||
|
privacy_link: link_to(t("pages.contacts.privacy_link"), public_privacy_path, target: "_blank", rel: "noopener")
|
||||||
|
) %>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<%= submit_tag t("pages.contacts.submit"), class: "btn btn-primary" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section wrap features-cta" aria-labelledby="contacts-cta-title">
|
||||||
|
<div class="features-cta__box">
|
||||||
|
<h2 id="contacts-cta-title"><%= t("pages.contacts.cta_title") %></h2>
|
||||||
|
<p><%= t("pages.contacts.cta_body") %></p>
|
||||||
|
<div class="features-cta__actions">
|
||||||
|
<%= link_to t("pages.contacts.cta_faq"), public_faq_path, class: "btn btn-outline" %>
|
||||||
|
<%= link_to t("pages.contacts.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
@@ -101,6 +101,12 @@
|
|||||||
<td><%= t("legal.cookies.s4_2_row3_duration") %></td>
|
<td><%= t("legal.cookies.s4_2_row3_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>
|
||||||
|
|||||||
@@ -83,6 +83,6 @@
|
|||||||
<div class="card" style="margin-top:32px;text-align:center">
|
<div class="card" style="margin-top:32px;text-align:center">
|
||||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||||
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
<%= link_to t("pages.pricing.contact_button"), public_contatti_path, class: "btn btn-primary" %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
<li><%= raw t("legal.privacy.s3_item4_html") %></li>
|
<li><%= raw t("legal.privacy.s3_item4_html") %></li>
|
||||||
<li><%= raw t("legal.privacy.s3_item5_html") %></li>
|
<li><%= raw t("legal.privacy.s3_item5_html") %></li>
|
||||||
<li><%= raw t("legal.privacy.s3_item6_html") %></li>
|
<li><%= raw t("legal.privacy.s3_item6_html") %></li>
|
||||||
|
<li><%= raw t("legal.privacy.s3_item7_html") %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -107,6 +108,10 @@
|
|||||||
<td><%= t("legal.privacy.s5_row6_purpose") %></td>
|
<td><%= t("legal.privacy.s5_row6_purpose") %></td>
|
||||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><%= t("legal.privacy.s5_row7_purpose") %></td>
|
||||||
|
<td><%= t("legal.privacy.s5_row7_basis") %></td>
|
||||||
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +149,7 @@
|
|||||||
<li><%= raw t("legal.privacy.s8_item2_html") %></li>
|
<li><%= raw t("legal.privacy.s8_item2_html") %></li>
|
||||||
<li><%= raw t("legal.privacy.s8_item3_html") %></li>
|
<li><%= raw t("legal.privacy.s8_item3_html") %></li>
|
||||||
<li><%= raw t("legal.privacy.s8_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
|
<li><%= raw t("legal.privacy.s8_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
|
||||||
|
<li><%= raw t("legal.privacy.s8_item5_html") %></li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<% content_for :title, t("team.edit.title", name: @team.name) %>
|
<% content_for :title, t("team.edit.title", name: @team.name) %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% content_for :robots, "noindex, nofollow" %>
|
||||||
|
|
||||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
<div class="wrap wrap--portal" style="padding-top:20px">
|
||||||
<h1><%= @team.name %></h1>
|
<h1><%= @team.name %></h1>
|
||||||
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
|
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<% record = local_assigns[:record] %>
|
<% record = local_assigns[:record] %>
|
||||||
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
|
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
|
||||||
<% can_upload = local_assigns.fetch(:can_upload, false) %>
|
<% can_upload = local_assigns.fetch(:can_upload, false) %>
|
||||||
<% cover_src = record.effective_cover_url.presence || Streams::CoverResolver::DEFAULT_COVER_URL %>
|
<% default_cover = Streams::CoverResolver::DEFAULT_COVER_URL %>
|
||||||
|
<% cover_src = record.effective_cover_url.presence || default_cover %>
|
||||||
|
<% remove_id = "remove_cover_#{record.model_name.param_key}" %>
|
||||||
|
|
||||||
<fieldset class="branding-fields cover-fields">
|
<fieldset class="branding-fields cover-fields">
|
||||||
<legend><%= t("club.branding.cover_legend") %></legend>
|
<legend><%= t("club.branding.cover_legend") %></legend>
|
||||||
@@ -22,16 +24,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<% if can_upload %>
|
<% if can_upload %>
|
||||||
<%= file_field_tag "branding[cover_image]",
|
<div class="cover-actions">
|
||||||
accept: "image/png,image/jpeg,image/webp",
|
<%= file_field_tag "branding[cover_image]",
|
||||||
data: { branding_cover_file: true },
|
accept: "image/png,image/jpeg,image/webp",
|
||||||
class: "branding-file-input" %>
|
data: { branding_cover_file: true },
|
||||||
<% if record.cover_image_attached? %>
|
class: "branding-file-input" %>
|
||||||
<label class="checkbox-label cover-remove-label">
|
<% if record.cover_image_attached? %>
|
||||||
<%= check_box_tag "remove_cover", "1", false, id: "remove_cover_#{record.model_name.param_key}" %>
|
<%= hidden_field_tag "remove_cover", "0", id: remove_id, data: { branding_cover_remove: true } %>
|
||||||
<%= t("club.branding.cover_remove_label") %>
|
<button type="button"
|
||||||
</label>
|
class="btn btn-secondary cover-reset-btn"
|
||||||
<% end %>
|
data-branding-cover-reset
|
||||||
|
data-default-cover="<%= default_cover %>"
|
||||||
|
data-remove-input="#<%= remove_id %>">
|
||||||
|
<%= t("club.branding.cover_reset_button") %>
|
||||||
|
</button>
|
||||||
|
<p class="branding-hint cover-reset-hint" data-branding-cover-reset-hint hidden>
|
||||||
|
<%= t("club.branding.cover_reset_pending_hint") %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p class="muted"><%= t("club.branding.cover_requires_full") %></p>
|
<p class="muted"><%= t("club.branding.cover_requires_full") %></p>
|
||||||
<%= link_to t("club.branding.cover_see_plans"), public_prezzi_path, class: "btn btn-secondary btn-sm" %>
|
<%= link_to t("club.branding.cover_see_plans"), public_prezzi_path, class: "btn btn-secondary btn-sm" %>
|
||||||
@@ -43,13 +54,29 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var input = document.querySelector("[data-branding-cover-file]");
|
var input = document.querySelector("[data-branding-cover-file]");
|
||||||
var img = document.querySelector("[data-branding-cover-img]");
|
var img = document.querySelector("[data-branding-cover-img]");
|
||||||
if (!input || !img) return;
|
var resetBtn = document.querySelector("[data-branding-cover-reset]");
|
||||||
input.addEventListener("change", function () {
|
var removeInput = document.querySelector("[data-branding-cover-remove]");
|
||||||
var file = input.files && input.files[0];
|
var resetHint = document.querySelector("[data-branding-cover-reset-hint]");
|
||||||
if (!file) return;
|
if (input && img) {
|
||||||
var reader = new FileReader();
|
input.addEventListener("change", function () {
|
||||||
reader.onload = function (e) { img.src = e.target.result; };
|
var file = input.files && input.files[0];
|
||||||
reader.readAsDataURL(file);
|
if (!file) return;
|
||||||
});
|
if (removeInput) removeInput.value = "0";
|
||||||
|
if (resetHint) resetHint.hidden = true;
|
||||||
|
if (resetBtn) resetBtn.disabled = false;
|
||||||
|
var reader = new FileReader();
|
||||||
|
reader.onload = function (e) { img.src = e.target.result; };
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (resetBtn && img && removeInput) {
|
||||||
|
resetBtn.addEventListener("click", function () {
|
||||||
|
removeInput.value = "1";
|
||||||
|
img.src = resetBtn.getAttribute("data-default-cover");
|
||||||
|
if (input) input.value = "";
|
||||||
|
if (resetHint) resetHint.hidden = false;
|
||||||
|
resetBtn.disabled = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>
|
<div class="site-footer__brand">
|
||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
<%= render "shared/store_badges", variant: "footer" %>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="site-footer__nav">
|
||||||
|
<%= link_to t("common.contacts"), public_contatti_path %> ·
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||||
@@ -14,6 +14,10 @@
|
|||||||
<%= link_to t("common.terms"), public_termini_path %>
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-footer__apps">
|
||||||
|
<%= render "shared/store_badges", variant: "footer" %>
|
||||||
|
<%= render "shared/social_links", modifier: "footer" %>
|
||||||
|
</div>
|
||||||
<div class="site-footer__legal">
|
<div class="site-footer__legal">
|
||||||
<p><%= t("footer.copyright") %></p>
|
<p><%= t("footer.copyright") %></p>
|
||||||
<p><%= t("footer.responsibility") %></p>
|
<p><%= t("footer.responsibility") %></p>
|
||||||
|
|||||||
@@ -1,16 +1,19 @@
|
|||||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<div class="wrap">
|
<div class="wrap">
|
||||||
<div>
|
<div class="site-footer__brand">
|
||||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div class="site-footer__nav">
|
||||||
<%= link_to t("common.support"), public_support_path %> ·
|
<%= link_to t("common.support"), public_support_path %> ·
|
||||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||||
<%= link_to t("common.terms"), public_termini_path %>
|
<%= link_to t("common.terms"), public_termini_path %>
|
||||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="site-footer__apps">
|
||||||
|
<%= render "shared/social_links", modifier: "footer" %>
|
||||||
|
</div>
|
||||||
<div class="site-footer__legal">
|
<div class="site-footer__legal">
|
||||||
<p><%= t("footer.copyright") %></p>
|
<p><%= t("footer.copyright") %></p>
|
||||||
<p><%= t("footer.responsibility") %></p>
|
<p><%= t("footer.responsibility") %></p>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<% announcements = @site_announcements %>
|
||||||
|
<% if announcements.present? %>
|
||||||
|
<div class="site-announcements" role="region" aria-label="<%= t("announcement.region") %>">
|
||||||
|
<% announcements.each do |item| %>
|
||||||
|
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
||||||
|
data-announcement-id="<%= item.id %>">
|
||||||
|
<div class="site-announcement__body">
|
||||||
|
<p class="site-announcement__title"><%= item.localized_title %></p>
|
||||||
|
<div class="site-announcement__text"><%= simple_format(item.localized_body) %></div>
|
||||||
|
<% action_url = item.resolved_action_url(nil) %>
|
||||||
|
<% if action_url.present? %>
|
||||||
|
<p class="site-announcement__action">
|
||||||
|
<%= link_to (item.localized_action_label.presence || t("announcement.open")), action_url,
|
||||||
|
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<% if item.dismissible? %>
|
||||||
|
<button type="button" class="site-announcement__dismiss" data-announcement-dismiss aria-label="<%= t("announcement.dismiss") %>">
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
</article>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var key = "mltv.webDismissedAnnouncements";
|
||||||
|
var dismissed = [];
|
||||||
|
try { dismissed = JSON.parse(localStorage.getItem(key) || "[]"); } catch (e) {}
|
||||||
|
document.querySelectorAll(".site-announcement[data-announcement-id]").forEach(function (el) {
|
||||||
|
if (dismissed.indexOf(el.getAttribute("data-announcement-id")) !== -1) el.remove();
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-announcement-dismiss]").forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var box = btn.closest("[data-announcement-id]");
|
||||||
|
if (!box) return;
|
||||||
|
var id = box.getAttribute("data-announcement-id");
|
||||||
|
if (dismissed.indexOf(id) === -1) dismissed.push(id);
|
||||||
|
localStorage.setItem(key, JSON.stringify(dismissed));
|
||||||
|
box.remove();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<%# Social links: Facebook, Instagram, TikTok, YouTube — glyph brand-color su sfondo trasparente. %>
|
||||||
|
<% urls = {
|
||||||
|
facebook: "https://www.facebook.com/matchlivetvapp",
|
||||||
|
instagram: "https://www.instagram.com/matchlivetv_app/",
|
||||||
|
tiktok: "https://www.tiktok.com/@matchlivetv_app",
|
||||||
|
youtube: "https://www.youtube.com/@SportMatchLiveTv"
|
||||||
|
} %>
|
||||||
|
<% ig_grad_id = "ig-grad-#{SecureRandom.hex(4)}" %>
|
||||||
|
<nav class="site-social<%= local_assigns[:modifier].present? ? " site-social--#{modifier}" : "" %>" aria-label="<%= t("footer.social_nav") %>">
|
||||||
|
<a class="site-social__link site-social__link--facebook" href="<%= urls[:facebook] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_facebook") %>">
|
||||||
|
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="currentColor" d="M14 8.2h2.2V5h-2.2C11.7 5 10 6.8 10 9.2V11H8v3.2h2V19h3.2v-4.8h2.2l.6-3.2h-2.8V9.2c0-.6.4-1 1-1z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a class="site-social__link site-social__link--instagram" href="<%= urls[:instagram] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_instagram") %>">
|
||||||
|
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="<%= ig_grad_id %>" x1="0%" y1="100%" x2="100%" y2="0%">
|
||||||
|
<stop offset="0%" stop-color="#f58529"/>
|
||||||
|
<stop offset="45%" stop-color="#dd2a7b"/>
|
||||||
|
<stop offset="100%" stop-color="#515bd4"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<path fill="url(#<%= ig_grad_id %>)" d="M12 7.2A4.8 4.8 0 1 0 12 16.8 4.8 4.8 0 0 0 12 7.2zm0 7.7a2.9 2.9 0 1 1 0-5.8 2.9 2.9 0 0 1 0 5.8z"/>
|
||||||
|
<circle fill="url(#<%= ig_grad_id %>)" cx="17.4" cy="6.7" r="1.1"/>
|
||||||
|
<path fill="url(#<%= ig_grad_id %>)" d="M12 2.5c-2.6 0-2.9 0-3.9.1-2.6.1-3.9 1.4-4 4-.1 1-.1 1.3-.1 3.9s0 2.9.1 3.9c.1 2.6 1.4 3.9 4 4 1 .1 1.3.1 3.9.1s2.9 0 3.9-.1c2.6-.1 3.9-1.4 4-4 .1-1 .1-1.3.1-3.9s0-2.9-.1-3.9c-.1-2.6-1.4-3.9-4-4-1-.1-1.3-.1-3.9-.1zm0 1.7c2.5 0 2.8 0 3.8.1 1.8.1 2.7.9 2.8 2.8.1 1 .1 1.3.1 3.8s0 2.8-.1 3.8c-.1 1.8-.9 2.7-2.8 2.8-1 .1-1.3.1-3.8.1s-2.8 0-3.8-.1c-1.8-.1-2.7-.9-2.8-2.8-.1-1-.1-1.3-.1-3.8s0-2.8.1-3.8c.1-1.8 1-2.7 2.8-2.8 1-.1 1.3-.1 3.8-.1z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a class="site-social__link site-social__link--tiktok" href="<%= urls[:tiktok] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_tiktok") %>">
|
||||||
|
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="#69C9D0" transform="translate(1.15 0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
|
||||||
|
<path fill="#EE1D52" transform="translate(-1.15 -0.75)" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
|
||||||
|
<path fill="#fff" d="M19.1 8.3a5.7 5.7 0 0 1-3.4-1.1v6.2a5.4 5.4 0 1 1-5.4-5.4c.3 0 .5 0 .8.1v2.7a2.7 2.7 0 1 0 1.9 2.6V2.5h2.6a5.7 5.7 0 0 0 3.5 3.4v2.4z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
<a class="site-social__link site-social__link--youtube" href="<%= urls[:youtube] %>" target="_blank" rel="noopener noreferrer" aria-label="<%= t("footer.social_youtube") %>">
|
||||||
|
<svg class="site-social__icon" viewBox="0 0 24 24" width="36" height="36" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="currentColor" d="M23.5 7.2a3 3 0 0 0-2.1-2.1C19.5 4.6 12 4.6 12 4.6s-7.5 0-9.4.5A3 3 0 0 0 .5 7.2 31.5 31.5 0 0 0 0 12a31.5 31.5 0 0 0 .5 4.8 3 3 0 0 0 2.1 2.1c1.9.5 9.4.5 9.4.5s7.5 0 9.4-.5a3 3 0 0 0 2.1-2.1A31.5 31.5 0 0 0 24 12a31.5 31.5 0 0 0-.5-4.8zM9.6 15.5v-7l6.3 3.5-6.3 3.5z"/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</nav>
|
||||||
@@ -5,10 +5,12 @@ de:
|
|||||||
nav:
|
nav:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ops: Ops
|
ops: Ops
|
||||||
|
announcements: Hinweise
|
||||||
clubs: Vereine & Teams
|
clubs: Vereine & Teams
|
||||||
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
|
||||||
@@ -45,6 +47,9 @@ de:
|
|||||||
invoice_updated: "Rechnung %{number} aktualisiert."
|
invoice_updated: "Rechnung %{number} aktualisiert."
|
||||||
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
|
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
|
||||||
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
|
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
|
||||||
|
announcement_created: Hinweis gespeichert
|
||||||
|
announcement_updated: Hinweis aktualisiert
|
||||||
|
announcement_destroyed: Hinweis gelöscht
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Ja"
|
yes: "Ja"
|
||||||
@@ -106,6 +111,8 @@ de:
|
|||||||
table:
|
table:
|
||||||
match: Spiel
|
match: Spiel
|
||||||
status: Status
|
status: Status
|
||||||
|
client: Client
|
||||||
|
ingest: Ingest
|
||||||
start: Start
|
start: Start
|
||||||
link: Link
|
link: Link
|
||||||
none: Derzeit keine aktive Sitzung.
|
none: Derzeit keine aktive Sitzung.
|
||||||
@@ -246,27 +253,94 @@ 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
|
||||||
|
client: Client
|
||||||
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
|
||||||
|
client_os: System
|
||||||
|
app_version: App-Version
|
||||||
|
device: Gerät
|
||||||
|
os_version: OS-Version
|
||||||
|
carrier: Mobilfunkanbieter
|
||||||
|
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.
|
||||||
@@ -277,6 +351,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
|
||||||
@@ -360,3 +482,68 @@ de:
|
|||||||
club_recordings:
|
club_recordings:
|
||||||
index:
|
index:
|
||||||
title: "Replays — %{club}"
|
title: "Replays — %{club}"
|
||||||
|
announcements:
|
||||||
|
index:
|
||||||
|
title: Hinweise
|
||||||
|
lead: Meldungen für die Apps, die öffentliche Website und den Login-Bereich — Wartung, Updates oder Mitteilungen.
|
||||||
|
new: Neuer Hinweis
|
||||||
|
table_title: Hinweise
|
||||||
|
none: Noch keine Hinweise. Erstelle einen, um Apps und Website zu informieren.
|
||||||
|
new:
|
||||||
|
title: Neuer Hinweis
|
||||||
|
edit:
|
||||||
|
title: Hinweis bearbeiten
|
||||||
|
table:
|
||||||
|
status: Status
|
||||||
|
kind: Typ
|
||||||
|
title: Nachricht
|
||||||
|
channels: Ziele
|
||||||
|
window: Zeitraum
|
||||||
|
actions:
|
||||||
|
edit: Bearbeiten
|
||||||
|
delete: Löschen
|
||||||
|
delete_confirm: Diesen Hinweis löschen? Er erscheint nicht mehr an den gewählten Stellen.
|
||||||
|
kind:
|
||||||
|
info: Information
|
||||||
|
maintenance: Wartung
|
||||||
|
update: App-Update
|
||||||
|
severity:
|
||||||
|
info: Info
|
||||||
|
warning: Warnung
|
||||||
|
critical: Kritisch
|
||||||
|
status:
|
||||||
|
draft: Entwurf
|
||||||
|
published: Veröffentlicht
|
||||||
|
archived: Archiviert
|
||||||
|
channels:
|
||||||
|
android: Android-App
|
||||||
|
ios: iOS-App
|
||||||
|
web_public: Öffentliche Website
|
||||||
|
web_private: Login-Bereich
|
||||||
|
window:
|
||||||
|
always: Immer (solange veröffentlicht)
|
||||||
|
from: "Ab %{time}"
|
||||||
|
until: "Bis %{time}"
|
||||||
|
form:
|
||||||
|
kind: Typ
|
||||||
|
severity: Schwere
|
||||||
|
status: Status
|
||||||
|
channels: Wo anzeigen
|
||||||
|
channels_hint: "Eine oder mehrere Ziele wählen. Android und iOS sind unabhängig: bei unterschiedlichem Text zwei Hinweise anlegen. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto)."
|
||||||
|
title: Titel
|
||||||
|
body: Text
|
||||||
|
starts_at: Sichtbar ab
|
||||||
|
ends_at: Sichtbar bis
|
||||||
|
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
|
||||||
|
action_url: Link (optional)
|
||||||
|
action_url_hint: Bei Updates öffnet eine leere URL den Store der Plattform (Play Store auf Android, App Store auf iOS).
|
||||||
|
action_label: Button-Text
|
||||||
|
locales_legend: Text pro Sprache
|
||||||
|
locales_hint: Alle App-Sprachen ausfüllen (Italienisch, Englisch, Französisch, Deutsch, Spanisch). Italienisch ist Pflicht; fehlende Sprachen fallen auf Englisch, dann Italienisch zurück.
|
||||||
|
locale_required: Pflicht
|
||||||
|
dismissible: Nutzer können den Hinweis schließen
|
||||||
|
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
|
||||||
|
submit_create: Hinweis speichern
|
||||||
|
submit_update: Hinweis aktualisieren
|
||||||
|
cancel: Abbrechen
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ en:
|
|||||||
nav:
|
nav:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ops: Ops
|
ops: Ops
|
||||||
|
announcements: Notices
|
||||||
clubs: Clubs & teams
|
clubs: Clubs & teams
|
||||||
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
|
||||||
@@ -45,6 +47,9 @@ en:
|
|||||||
invoice_updated: "Invoice %{number} updated."
|
invoice_updated: "Invoice %{number} updated."
|
||||||
invoice_uploaded: "Invoice uploaded and sent to %{email}."
|
invoice_uploaded: "Invoice uploaded and sent to %{email}."
|
||||||
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
|
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
|
||||||
|
announcement_created: Notice saved
|
||||||
|
announcement_updated: Notice updated
|
||||||
|
announcement_destroyed: Notice deleted
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Yes"
|
yes: "Yes"
|
||||||
@@ -106,6 +111,8 @@ en:
|
|||||||
table:
|
table:
|
||||||
match: Match
|
match: Match
|
||||||
status: Status
|
status: Status
|
||||||
|
client: Client
|
||||||
|
ingest: Ingest
|
||||||
start: Start
|
start: Start
|
||||||
link: Link
|
link: Link
|
||||||
none: No active session right now.
|
none: No active session right now.
|
||||||
@@ -246,27 +253,94 @@ 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
|
||||||
|
client: Client
|
||||||
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
|
||||||
|
client_os: OS
|
||||||
|
app_version: App version
|
||||||
|
device: Device
|
||||||
|
os_version: OS version
|
||||||
|
carrier: Carrier
|
||||||
|
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.
|
||||||
@@ -277,6 +351,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
|
||||||
@@ -360,3 +482,68 @@ en:
|
|||||||
club_recordings:
|
club_recordings:
|
||||||
index:
|
index:
|
||||||
title: "Replays — %{club}"
|
title: "Replays — %{club}"
|
||||||
|
announcements:
|
||||||
|
index:
|
||||||
|
title: Notices
|
||||||
|
lead: Messages for the apps, the public website and the logged-in area — maintenance, updates or announcements.
|
||||||
|
new: New notice
|
||||||
|
table_title: Notices
|
||||||
|
none: No notices yet. Create one to notify the apps and the website.
|
||||||
|
new:
|
||||||
|
title: New notice
|
||||||
|
edit:
|
||||||
|
title: Edit notice
|
||||||
|
table:
|
||||||
|
status: Status
|
||||||
|
kind: Type
|
||||||
|
title: Message
|
||||||
|
channels: Destinations
|
||||||
|
window: Window
|
||||||
|
actions:
|
||||||
|
edit: Edit
|
||||||
|
delete: Delete
|
||||||
|
delete_confirm: Delete this notice? It will no longer appear in the selected destinations.
|
||||||
|
kind:
|
||||||
|
info: Information
|
||||||
|
maintenance: Maintenance
|
||||||
|
update: App update
|
||||||
|
severity:
|
||||||
|
info: Info
|
||||||
|
warning: Warning
|
||||||
|
critical: Critical
|
||||||
|
status:
|
||||||
|
draft: Draft
|
||||||
|
published: Published
|
||||||
|
archived: Archived
|
||||||
|
channels:
|
||||||
|
android: Android app
|
||||||
|
ios: iOS app
|
||||||
|
web_public: Public website
|
||||||
|
web_private: Logged-in area
|
||||||
|
window:
|
||||||
|
always: Always (while published)
|
||||||
|
from: "From %{time}"
|
||||||
|
until: "Until %{time}"
|
||||||
|
form:
|
||||||
|
kind: Type
|
||||||
|
severity: Severity
|
||||||
|
status: Status
|
||||||
|
channels: Where to show it
|
||||||
|
channels_hint: "Select one or more destinations. Android and iOS are independent: create two notices if the copy should differ. The logged-in area is visible after sign-in (club, teams, account)."
|
||||||
|
title: Title
|
||||||
|
body: Body
|
||||||
|
starts_at: Visible from
|
||||||
|
ends_at: Visible until
|
||||||
|
window_hint: Leave empty to show immediately while published.
|
||||||
|
action_url: Link (optional)
|
||||||
|
action_url_hint: For updates, an empty URL opens that platform's store (Play Store on Android, App Store on iOS).
|
||||||
|
action_label: Button label
|
||||||
|
locales_legend: Copy by language
|
||||||
|
locales_hint: Fill in every app language (Italian, English, French, German, Spanish). Italian is required; missing languages fall back to English, then Italian.
|
||||||
|
locale_required: required
|
||||||
|
dismissible: Users can dismiss the notice
|
||||||
|
dismissible_hint: Uncheck for maintenance notices that should stay visible.
|
||||||
|
submit_create: Save notice
|
||||||
|
submit_update: Update notice
|
||||||
|
cancel: Cancel
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ es:
|
|||||||
nav:
|
nav:
|
||||||
dashboard: Panel
|
dashboard: Panel
|
||||||
ops: Ops
|
ops: Ops
|
||||||
|
announcements: Avisos
|
||||||
clubs: Clubes y equipos
|
clubs: Clubes y equipos
|
||||||
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
|
||||||
@@ -45,6 +47,9 @@ es:
|
|||||||
invoice_updated: "Factura %{number} actualizada."
|
invoice_updated: "Factura %{number} actualizada."
|
||||||
invoice_uploaded: "Factura subida y enviada a %{email}."
|
invoice_uploaded: "Factura subida y enviada a %{email}."
|
||||||
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
|
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
|
||||||
|
announcement_created: Aviso guardado
|
||||||
|
announcement_updated: Aviso actualizado
|
||||||
|
announcement_destroyed: Aviso eliminado
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Sí"
|
yes: "Sí"
|
||||||
@@ -106,6 +111,8 @@ es:
|
|||||||
table:
|
table:
|
||||||
match: Partido
|
match: Partido
|
||||||
status: Estado
|
status: Estado
|
||||||
|
client: Cliente
|
||||||
|
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.
|
||||||
@@ -246,27 +253,94 @@ 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
|
||||||
|
client: Cliente
|
||||||
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
|
||||||
|
client_os: Sistema
|
||||||
|
app_version: Versión app
|
||||||
|
device: Dispositivo
|
||||||
|
os_version: Versión OS
|
||||||
|
carrier: Operador móvil
|
||||||
|
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.
|
||||||
@@ -277,6 +351,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
|
||||||
@@ -360,3 +482,68 @@ es:
|
|||||||
club_recordings:
|
club_recordings:
|
||||||
index:
|
index:
|
||||||
title: "Repeticiones — %{club}"
|
title: "Repeticiones — %{club}"
|
||||||
|
announcements:
|
||||||
|
index:
|
||||||
|
title: Avisos
|
||||||
|
lead: Mensajes hacia las apps, el sitio público y el área privada — mantenimiento, actualizaciones o comunicados.
|
||||||
|
new: Nuevo aviso
|
||||||
|
table_title: Avisos
|
||||||
|
none: Todavía no hay avisos. Crea uno para notificar a las apps y al sitio.
|
||||||
|
new:
|
||||||
|
title: Nuevo aviso
|
||||||
|
edit:
|
||||||
|
title: Editar aviso
|
||||||
|
table:
|
||||||
|
status: Estado
|
||||||
|
kind: Tipo
|
||||||
|
title: Mensaje
|
||||||
|
channels: Destinos
|
||||||
|
window: Ventana
|
||||||
|
actions:
|
||||||
|
edit: Editar
|
||||||
|
delete: Eliminar
|
||||||
|
delete_confirm: ¿Eliminar este aviso? Ya no se verá en los destinos elegidos.
|
||||||
|
kind:
|
||||||
|
info: Información
|
||||||
|
maintenance: Mantenimiento
|
||||||
|
update: Actualización
|
||||||
|
severity:
|
||||||
|
info: Info
|
||||||
|
warning: Atención
|
||||||
|
critical: Crítico
|
||||||
|
status:
|
||||||
|
draft: Borrador
|
||||||
|
published: Publicado
|
||||||
|
archived: Archivado
|
||||||
|
channels:
|
||||||
|
android: App Android
|
||||||
|
ios: App iOS
|
||||||
|
web_public: Sitio público
|
||||||
|
web_private: Área privada
|
||||||
|
window:
|
||||||
|
always: Siempre (mientras esté publicado)
|
||||||
|
from: "Desde %{time}"
|
||||||
|
until: "Hasta %{time}"
|
||||||
|
form:
|
||||||
|
kind: Tipo
|
||||||
|
severity: Gravedad
|
||||||
|
status: Estado
|
||||||
|
channels: Dónde mostrarlo
|
||||||
|
channels_hint: "Elige uno o más destinos. Android e iOS son independientes: si el texto debe ser distinto, crea dos avisos. El área privada se ve tras iniciar sesión (club, equipos, cuenta)."
|
||||||
|
title: Título
|
||||||
|
body: Texto
|
||||||
|
starts_at: Visible desde
|
||||||
|
ends_at: Visible hasta
|
||||||
|
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
|
||||||
|
action_url: Enlace (opcional)
|
||||||
|
action_url_hint: En actualizaciones, una URL vacía abre la tienda de esa plataforma (Play Store en Android, App Store en iOS).
|
||||||
|
action_label: Texto del botón
|
||||||
|
locales_legend: Texto por idioma
|
||||||
|
locales_hint: Completa todos los idiomas de la app (italiano, inglés, francés, alemán, español). El italiano es obligatorio; si falta un idioma, se usa inglés y luego italiano.
|
||||||
|
locale_required: obligatorio
|
||||||
|
dismissible: El usuario puede cerrar el aviso
|
||||||
|
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
|
||||||
|
submit_create: Guardar aviso
|
||||||
|
submit_update: Actualizar aviso
|
||||||
|
cancel: Cancelar
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ fr:
|
|||||||
nav:
|
nav:
|
||||||
dashboard: Tableau de bord
|
dashboard: Tableau de bord
|
||||||
ops: Ops
|
ops: Ops
|
||||||
|
announcements: Alertes
|
||||||
clubs: Clubs et équipes
|
clubs: Clubs et équipes
|
||||||
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
|
||||||
@@ -45,6 +47,9 @@ fr:
|
|||||||
invoice_updated: "Facture %{number} mise à jour."
|
invoice_updated: "Facture %{number} mise à jour."
|
||||||
invoice_uploaded: "Facture chargée et envoyée à %{email}."
|
invoice_uploaded: "Facture chargée et envoyée à %{email}."
|
||||||
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
|
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
|
||||||
|
announcement_created: Alerte enregistrée
|
||||||
|
announcement_updated: Alerte mise à jour
|
||||||
|
announcement_destroyed: Alerte supprimée
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Oui"
|
yes: "Oui"
|
||||||
@@ -106,6 +111,8 @@ fr:
|
|||||||
table:
|
table:
|
||||||
match: Match
|
match: Match
|
||||||
status: Statut
|
status: Statut
|
||||||
|
client: Client
|
||||||
|
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.
|
||||||
@@ -246,27 +253,94 @@ 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
|
||||||
|
client: Client
|
||||||
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
|
||||||
|
client_os: Système
|
||||||
|
app_version: Version app
|
||||||
|
device: Appareil
|
||||||
|
os_version: Version OS
|
||||||
|
carrier: Opérateur mobile
|
||||||
|
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.
|
||||||
@@ -277,6 +351,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
|
||||||
@@ -360,3 +482,68 @@ fr:
|
|||||||
club_recordings:
|
club_recordings:
|
||||||
index:
|
index:
|
||||||
title: "Replays — %{club}"
|
title: "Replays — %{club}"
|
||||||
|
announcements:
|
||||||
|
index:
|
||||||
|
title: Alertes
|
||||||
|
lead: Messages vers les apps, le site public et l'espace connecté — maintenance, mises à jour ou communications.
|
||||||
|
new: Nouvelle alerte
|
||||||
|
table_title: Alertes
|
||||||
|
none: Aucune alerte. Créez-en une pour notifier les applications et le site.
|
||||||
|
new:
|
||||||
|
title: Nouvelle alerte
|
||||||
|
edit:
|
||||||
|
title: Modifier l'alerte
|
||||||
|
table:
|
||||||
|
status: Statut
|
||||||
|
kind: Type
|
||||||
|
title: Message
|
||||||
|
channels: Destinations
|
||||||
|
window: Fenêtre
|
||||||
|
actions:
|
||||||
|
edit: Modifier
|
||||||
|
delete: Supprimer
|
||||||
|
delete_confirm: Supprimer cette alerte ? Elle n'apparaîtra plus aux destinations choisies.
|
||||||
|
kind:
|
||||||
|
info: Information
|
||||||
|
maintenance: Maintenance
|
||||||
|
update: Mise à jour
|
||||||
|
severity:
|
||||||
|
info: Info
|
||||||
|
warning: Attention
|
||||||
|
critical: Critique
|
||||||
|
status:
|
||||||
|
draft: Brouillon
|
||||||
|
published: Publié
|
||||||
|
archived: Archivé
|
||||||
|
channels:
|
||||||
|
android: App Android
|
||||||
|
ios: App iOS
|
||||||
|
web_public: Site public
|
||||||
|
web_private: Espace connecté
|
||||||
|
window:
|
||||||
|
always: Toujours (tant que publié)
|
||||||
|
from: "À partir de %{time}"
|
||||||
|
until: "Jusqu'à %{time}"
|
||||||
|
form:
|
||||||
|
kind: Type
|
||||||
|
severity: Gravité
|
||||||
|
status: Statut
|
||||||
|
channels: Où l'afficher
|
||||||
|
channels_hint: "Choisissez une ou plusieurs destinations. Android et iOS sont indépendants : créez deux alertes si le texte doit différer. L'espace connecté est visible après connexion (club, équipes, compte)."
|
||||||
|
title: Titre
|
||||||
|
body: Texte
|
||||||
|
starts_at: Visible à partir de
|
||||||
|
ends_at: Visible jusqu'à
|
||||||
|
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
|
||||||
|
action_url: Lien (facultatif)
|
||||||
|
action_url_hint: Pour une mise à jour, une URL vide ouvre le store de la plateforme (Play Store sur Android, App Store sur iOS).
|
||||||
|
action_label: Libellé du bouton
|
||||||
|
locales_legend: Texte par langue
|
||||||
|
locales_hint: Renseignez toutes les langues de l'app (italien, anglais, français, allemand, espagnol). L'italien est obligatoire ; les langues manquantes basculent vers l'anglais puis l'italien.
|
||||||
|
locale_required: obligatoire
|
||||||
|
dismissible: L'utilisateur peut fermer l'alerte
|
||||||
|
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
|
||||||
|
submit_create: Enregistrer l'alerte
|
||||||
|
submit_update: Mettre à jour l'alerte
|
||||||
|
cancel: Annuler
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ it:
|
|||||||
nav:
|
nav:
|
||||||
dashboard: Dashboard
|
dashboard: Dashboard
|
||||||
ops: Ops
|
ops: Ops
|
||||||
|
announcements: Avvisi
|
||||||
clubs: Società e squadre
|
clubs: Società e squadre
|
||||||
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
|
||||||
@@ -49,6 +51,9 @@ it:
|
|||||||
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
|
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
|
||||||
transfer_cancelled: Bonifico in attesa annullato.
|
transfer_cancelled: Bonifico in attesa annullato.
|
||||||
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
|
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
|
||||||
|
announcement_created: Avviso salvato
|
||||||
|
announcement_updated: Avviso aggiornato
|
||||||
|
announcement_destroyed: Avviso eliminato
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Sì"
|
yes: "Sì"
|
||||||
@@ -110,6 +115,8 @@ it:
|
|||||||
table:
|
table:
|
||||||
match: Partita
|
match: Partita
|
||||||
status: Stato
|
status: Stato
|
||||||
|
client: Client
|
||||||
|
ingest: Ingest
|
||||||
start: Inizio
|
start: Inizio
|
||||||
link: Link
|
link: Link
|
||||||
none: Nessuna sessione attiva in questo momento.
|
none: Nessuna sessione attiva in questo momento.
|
||||||
@@ -267,27 +274,94 @@ 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
|
||||||
|
client: Client
|
||||||
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
|
||||||
|
client_os: Sistema
|
||||||
|
app_version: Versione app
|
||||||
|
device: Dispositivo
|
||||||
|
os_version: Versione OS
|
||||||
|
carrier: Operatore telefonico
|
||||||
|
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.
|
||||||
@@ -298,6 +372,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
|
||||||
@@ -398,3 +520,68 @@ it:
|
|||||||
club_recordings:
|
club_recordings:
|
||||||
index:
|
index:
|
||||||
title: "Replay — %{club}"
|
title: "Replay — %{club}"
|
||||||
|
announcements:
|
||||||
|
index:
|
||||||
|
title: Avvisi
|
||||||
|
lead: Messaggi verso le app, il sito pubblico e l'area riservata — manutenzione, aggiornamenti o comunicazioni.
|
||||||
|
new: Nuovo avviso
|
||||||
|
table_title: Avvisi
|
||||||
|
none: Nessun avviso. Crea il primo per notificare app e sito.
|
||||||
|
new:
|
||||||
|
title: Nuovo avviso
|
||||||
|
edit:
|
||||||
|
title: Modifica avviso
|
||||||
|
table:
|
||||||
|
status: Stato
|
||||||
|
kind: Tipo
|
||||||
|
title: Messaggio
|
||||||
|
channels: Destinazioni
|
||||||
|
window: Finestra
|
||||||
|
actions:
|
||||||
|
edit: Modifica
|
||||||
|
delete: Elimina
|
||||||
|
delete_confirm: Eliminare questo avviso? Non sarà più visibile nelle destinazioni scelte.
|
||||||
|
kind:
|
||||||
|
info: Informazione
|
||||||
|
maintenance: Manutenzione
|
||||||
|
update: Aggiornamento app
|
||||||
|
severity:
|
||||||
|
info: Info
|
||||||
|
warning: Attenzione
|
||||||
|
critical: Critico
|
||||||
|
status:
|
||||||
|
draft: Bozza
|
||||||
|
published: Pubblicato
|
||||||
|
archived: Archiviato
|
||||||
|
channels:
|
||||||
|
android: App Android
|
||||||
|
ios: App iOS
|
||||||
|
web_public: Sito pubblico
|
||||||
|
web_private: Area riservata
|
||||||
|
window:
|
||||||
|
always: Sempre (finché pubblicato)
|
||||||
|
from: "Da %{time}"
|
||||||
|
until: "Fino a %{time}"
|
||||||
|
form:
|
||||||
|
kind: Tipo
|
||||||
|
severity: Gravità
|
||||||
|
status: Stato
|
||||||
|
channels: Dove mostrarlo
|
||||||
|
channels_hint: "Puoi selezionare una o più destinazioni. Android e iOS sono indipendenti: se il testo deve essere diverso, crea due avvisi. L'area riservata è visibile dopo il login (società, squadre, account)."
|
||||||
|
title: Titolo
|
||||||
|
body: Testo
|
||||||
|
starts_at: Inizio visibilità
|
||||||
|
ends_at: Fine visibilità
|
||||||
|
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
|
||||||
|
action_url: Link (facoltativo)
|
||||||
|
action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store della piattaforma (Play Store su Android, App Store su iOS).
|
||||||
|
action_label: Etichetta del pulsante
|
||||||
|
locales_legend: Testo per lingua
|
||||||
|
locales_hint: Compila tutte le lingue dell'app (italiano, inglese, francese, tedesco, spagnolo). L'italiano è obbligatorio; se una lingua manca, l'app usa inglese e poi italiano.
|
||||||
|
locale_required: obbligatorio
|
||||||
|
dismissible: L'utente può chiudere l'avviso
|
||||||
|
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
|
||||||
|
submit_create: Salva avviso
|
||||||
|
submit_update: Aggiorna avviso
|
||||||
|
cancel: Annulla
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ de:
|
|||||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||||
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:
|
||||||
|
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
|
||||||
@@ -700,6 +704,13 @@ de:
|
|||||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||||
password_mismatch: Die Passwörter stimmen nicht überein
|
password_mismatch: Die Passwörter stimmen nicht überein
|
||||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||||
|
contacts:
|
||||||
|
sent: Nachricht gesendet. Wir antworten an die angegebene E-Mail-Adresse.
|
||||||
|
throttled: Du hast zu viele Anfragen gesendet. Bitte versuche es in einer Stunde erneut.
|
||||||
|
invalid: Prüfe die Pflichtfelder und versuche es erneut.
|
||||||
|
invalid_email: Gib eine gültige E-Mail-Adresse ein.
|
||||||
|
invalid_message: Die Nachricht ist zu kurz oder zu lang.
|
||||||
|
privacy_required: Zum Senden des Formulars musst du die Datenschutzerklärung akzeptieren.
|
||||||
accounts:
|
accounts:
|
||||||
name_required: Der Name ist erforderlich
|
name_required: Der Name ist erforderlich
|
||||||
profile_updated: Profil aktualisiert.
|
profile_updated: Profil aktualisiert.
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ en:
|
|||||||
too_short: "is too short (minimum is %{count} characters)"
|
too_short: "is too short (minimum is %{count} characters)"
|
||||||
too_long: "is too long (maximum is %{count} characters)"
|
too_long: "is too long (maximum is %{count} characters)"
|
||||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||||
|
app_announcement:
|
||||||
|
no_channel: Select at least one destination (apps, public website or logged-in area).
|
||||||
|
translation_too_long: "Translation %{locale}: a field is too long."
|
||||||
|
translation_invalid_url: "Translation %{locale}: the link must be an http(s) URL."
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Main sport
|
sport_label: Main sport
|
||||||
@@ -39,6 +43,8 @@ en:
|
|||||||
cover_file_label: Cover image
|
cover_file_label: Cover image
|
||||||
cover_preview_alt: Live cover preview
|
cover_preview_alt: Live cover preview
|
||||||
cover_remove_label: Remove custom cover
|
cover_remove_label: Remove custom cover
|
||||||
|
cover_reset_button: Restore standard cover
|
||||||
|
cover_reset_pending_hint: The standard cover will be restored when you save.
|
||||||
cover_requires_full: Custom sponsor covers are available on the Premium Full plan.
|
cover_requires_full: Custom sponsor covers are available on the Premium Full plan.
|
||||||
cover_see_plans: See plans
|
cover_see_plans: See plans
|
||||||
dashboard:
|
dashboard:
|
||||||
@@ -703,6 +709,13 @@ en:
|
|||||||
password_same_as_current: New password must be different from the current password
|
password_same_as_current: New password must be different from the current password
|
||||||
password_mismatch: Passwords don't match
|
password_mismatch: Passwords don't match
|
||||||
password_updated: Password updated. You can now log in.
|
password_updated: Password updated. You can now log in.
|
||||||
|
contacts:
|
||||||
|
sent: Message sent. We will reply to the email you provided.
|
||||||
|
throttled: You have sent too many requests. Please try again in an hour.
|
||||||
|
invalid: Check the required fields and try again.
|
||||||
|
invalid_email: Enter a valid email address.
|
||||||
|
invalid_message: The message is too short or too long.
|
||||||
|
privacy_required: To send the form you must accept the privacy notice.
|
||||||
accounts:
|
accounts:
|
||||||
name_required: Name is required
|
name_required: Name is required
|
||||||
profile_updated: Profile updated.
|
profile_updated: Profile updated.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ es:
|
|||||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
|
app_announcement:
|
||||||
|
no_channel: Elige al menos un destino (apps, sitio público o área privada).
|
||||||
|
translation_too_long: "Traducción %{locale}: un campo es demasiado largo."
|
||||||
|
translation_invalid_url: "Traducción %{locale}: el enlace debe ser una URL http(s)."
|
||||||
club:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Deporte principal
|
sport_label: Deporte principal
|
||||||
@@ -700,6 +704,13 @@ es:
|
|||||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||||
password_mismatch: Las contraseñas no coinciden
|
password_mismatch: Las contraseñas no coinciden
|
||||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||||
|
contacts:
|
||||||
|
sent: Mensaje enviado. Te responderemos al email indicado.
|
||||||
|
throttled: Has enviado demasiadas solicitudes. Inténtalo de nuevo dentro de una hora.
|
||||||
|
invalid: Revisa los campos obligatorios e inténtalo de nuevo.
|
||||||
|
invalid_email: Introduce una dirección de email válida.
|
||||||
|
invalid_message: El mensaje es demasiado corto o demasiado largo.
|
||||||
|
privacy_required: Para enviar el formulario debes aceptar la política de privacidad.
|
||||||
accounts:
|
accounts:
|
||||||
name_required: El nombre es obligatorio
|
name_required: El nombre es obligatorio
|
||||||
profile_updated: Perfil actualizado.
|
profile_updated: Perfil actualizado.
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ fr:
|
|||||||
too_short: "est trop court (minimum %{count} caractères)"
|
too_short: "est trop court (minimum %{count} caractères)"
|
||||||
too_long: "est trop long (maximum %{count} caractères)"
|
too_long: "est trop long (maximum %{count} caractères)"
|
||||||
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:
|
||||||
|
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
|
||||||
@@ -700,6 +704,13 @@ fr:
|
|||||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||||
password_mismatch: Les mots de passe ne correspondent pas
|
password_mismatch: Les mots de passe ne correspondent pas
|
||||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||||
|
contacts:
|
||||||
|
sent: Message envoyé. Nous te répondrons à l'email indiqué.
|
||||||
|
throttled: Tu as envoyé trop de demandes. Réessaie dans une heure.
|
||||||
|
invalid: Vérifie les champs obligatoires et réessaie.
|
||||||
|
invalid_email: Saisis une adresse email valide.
|
||||||
|
invalid_message: Le message est trop court ou trop long.
|
||||||
|
privacy_required: Pour envoyer le formulaire tu dois accepter la notice de confidentialité.
|
||||||
accounts:
|
accounts:
|
||||||
name_required: Le nom est obligatoire
|
name_required: Le nom est obligatoire
|
||||||
profile_updated: Profil mis à jour.
|
profile_updated: Profil mis à jour.
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ it:
|
|||||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||||
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:
|
||||||
|
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
|
||||||
@@ -39,6 +43,8 @@ it:
|
|||||||
cover_file_label: Immagine copertina
|
cover_file_label: Immagine copertina
|
||||||
cover_preview_alt: Anteprima copertina diretta
|
cover_preview_alt: Anteprima copertina diretta
|
||||||
cover_remove_label: Rimuovi copertina personalizzata
|
cover_remove_label: Rimuovi copertina personalizzata
|
||||||
|
cover_reset_button: Ripristina copertina standard
|
||||||
|
cover_reset_pending_hint: Verrà ripristinata la copertina standard al salvataggio.
|
||||||
cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full.
|
cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full.
|
||||||
cover_see_plans: Vedi piani
|
cover_see_plans: Vedi piani
|
||||||
dashboard:
|
dashboard:
|
||||||
@@ -729,6 +735,13 @@ it:
|
|||||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||||
password_mismatch: Le password non coincidono
|
password_mismatch: Le password non coincidono
|
||||||
password_updated: Password aggiornata. Ora puoi accedere.
|
password_updated: Password aggiornata. Ora puoi accedere.
|
||||||
|
contacts:
|
||||||
|
sent: Messaggio inviato. Ti rispondiamo all'email indicata.
|
||||||
|
throttled: Hai inviato troppe richieste. Riprova tra un'ora.
|
||||||
|
invalid: Controlla i campi obbligatori e riprova.
|
||||||
|
invalid_email: Inserisci un indirizzo email valido.
|
||||||
|
invalid_message: Il messaggio è troppo corto o troppo lungo.
|
||||||
|
privacy_required: Per inviare il modulo devi accettare l'informativa privacy.
|
||||||
accounts:
|
accounts:
|
||||||
name_required: Il nome è obbligatorio
|
name_required: Il nome è obbligatorio
|
||||||
profile_updated: Profilo aggiornato.
|
profile_updated: Profilo aggiornato.
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ de:
|
|||||||
s3_item4_html: "<strong>Audiovisuelle Inhalte:</strong> Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
|
s3_item4_html: "<strong>Audiovisuelle Inhalte:</strong> Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
|
||||||
s3_item5_html: "<strong>Zahlungsdaten:</strong> Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
|
s3_item5_html: "<strong>Zahlungsdaten:</strong> Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
|
||||||
s3_item6_html: "<strong>Nutzungsdaten:</strong> Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
|
s3_item6_html: "<strong>Nutzungsdaten:</strong> Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
|
||||||
|
s3_item7_html: "<strong>Daten des Kontaktformulars:</strong> Name, E-Mail, optionaler Verein, Anliegen und Nachricht von der Kontaktseite."
|
||||||
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
|
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
|
||||||
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare <strong>minderjährige Athleten</strong> erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
|
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare <strong>minderjährige Athleten</strong> erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
|
||||||
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
|
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
|
||||||
@@ -56,6 +57,8 @@ de:
|
|||||||
s5_row5_basis: Vertragserfüllung
|
s5_row5_basis: Vertragserfüllung
|
||||||
s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung)
|
s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung)
|
||||||
s5_row6_basis: Einwilligung oder berechtigtes Interesse, je nach Fall
|
s5_row6_basis: Einwilligung oder berechtigtes Interesse, je nach Fall
|
||||||
|
s5_row7_purpose: Bearbeitung von Anfragen über das Kontaktformular
|
||||||
|
s5_row7_basis: Vorvertragliche Maßnahmen; berechtigtes Interesse an der Beantwortung von Anfragen
|
||||||
s6_title: 6. Empfänger und Auftragsverarbeiter
|
s6_title: 6. Empfänger und Auftragsverarbeiter
|
||||||
s6_intro: "Die Daten können an Dienstleister weitergegeben werden, die als Auftragsverarbeiter tätig sind, unter anderem:"
|
s6_intro: "Die Daten können an Dienstleister weitergegeben werden, die als Auftragsverarbeiter tätig sind, unter anderem:"
|
||||||
s6_item1: Anbieter von Hosting- und Cloud-/Serverinfrastruktur;
|
s6_item1: Anbieter von Hosting- und Cloud-/Serverinfrastruktur;
|
||||||
@@ -72,6 +75,7 @@ de:
|
|||||||
s8_item2_html: "<strong>Spielaufzeichnungen (Replays):</strong> je nach Paket (z. B. 30 oder 90 Tage), danach automatische Löschung oder Löschung auf Anfrage."
|
s8_item2_html: "<strong>Spielaufzeichnungen (Replays):</strong> je nach Paket (z. B. 30 oder 90 Tage), danach automatische Löschung oder Löschung auf Anfrage."
|
||||||
s8_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 30–90 Tage), sofern keine Sicherheitspflichten entgegenstehen."
|
s8_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 30–90 Tage), sofern keine Sicherheitspflichten entgegenstehen."
|
||||||
s8_item4_html: "<strong>Token zum Zurücksetzen des Passworts:</strong> maximale Gültigkeit %{hours} Stunden."
|
s8_item4_html: "<strong>Token zum Zurücksetzen des Passworts:</strong> maximale Gültigkeit %{hours} Stunden."
|
||||||
|
s8_item5_html: "<strong>Anfragen über das Kontaktformular:</strong> nur so lange gespeichert, wie zur Bearbeitung der Anfrage erforderlich, danach aus dem betrieblichen Postfach gelöscht."
|
||||||
s9_title: 9. Rechte der betroffenen Personen
|
s9_title: 9. Rechte der betroffenen Personen
|
||||||
s9_intro: "Als betroffene Person haben Sie das Recht:"
|
s9_intro: "Als betroffene Person haben Sie das Recht:"
|
||||||
s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie;
|
s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie;
|
||||||
@@ -84,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
|
||||||
@@ -165,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
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ en:
|
|||||||
s3_item4_html: "<strong>Audiovisual content:</strong> video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
|
s3_item4_html: "<strong>Audiovisual content:</strong> video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
|
||||||
s3_item5_html: "<strong>Payment data:</strong> for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
|
s3_item5_html: "<strong>Payment data:</strong> for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
|
||||||
s3_item6_html: "<strong>Browsing data:</strong> server logs, IP address, user agent, needed for the security and operation of the site."
|
s3_item6_html: "<strong>Browsing data:</strong> server logs, IP address, user agent, needed for the security and operation of the site."
|
||||||
|
s3_item7_html: "<strong>Contact form data:</strong> name, email, optional club, topic and message submitted from the Contact page."
|
||||||
s4_title: 4. Processing of images and data of minor athletes
|
s4_title: 4. Processing of images and data of minor athletes
|
||||||
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable <strong>minor athletes</strong> (image, voice, jersey number, game data)."
|
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable <strong>minor athletes</strong> (image, voice, jersey number, game data)."
|
||||||
s4_lead1: Who is responsible for processing images of minors
|
s4_lead1: Who is responsible for processing images of minors
|
||||||
@@ -56,6 +57,8 @@ en:
|
|||||||
s5_row5_basis: Performance of a contract
|
s5_row5_basis: Performance of a contract
|
||||||
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
|
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
|
||||||
s5_row6_basis: Consent or legitimate interest, depending on the case
|
s5_row6_basis: Consent or legitimate interest, depending on the case
|
||||||
|
s5_row7_purpose: Handling requests sent via the contact form
|
||||||
|
s5_row7_basis: Pre-contractual measures; legitimate interest in answering enquiries
|
||||||
s6_title: 6. Recipients and data processors
|
s6_title: 6. Recipients and data processors
|
||||||
s6_intro: "Data may be shared with providers acting as data processors, including:"
|
s6_intro: "Data may be shared with providers acting as data processors, including:"
|
||||||
s6_item1: hosting and cloud/server infrastructure provider;
|
s6_item1: hosting and cloud/server infrastructure provider;
|
||||||
@@ -72,6 +75,7 @@ en:
|
|||||||
s8_item2_html: "<strong>Match recordings (replays):</strong> according to the plan (e.g. 30 or 90 days), then automatic deletion or deletion on request."
|
s8_item2_html: "<strong>Match recordings (replays):</strong> according to the plan (e.g. 30 or 90 days), then automatic deletion or deletion on request."
|
||||||
s8_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 30–90 days), unless security obligations require otherwise."
|
s8_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 30–90 days), unless security obligations require otherwise."
|
||||||
s8_item4_html: "<strong>Password reset token:</strong> maximum validity %{hours} hours."
|
s8_item4_html: "<strong>Password reset token:</strong> maximum validity %{hours} hours."
|
||||||
|
s8_item5_html: "<strong>Contact form enquiries:</strong> kept only for as long as needed to handle the request, then deleted from the operational mailbox."
|
||||||
s9_title: 9. Data subject rights
|
s9_title: 9. Data subject rights
|
||||||
s9_intro: "As a data subject you have the right to:"
|
s9_intro: "As a data subject you have the right to:"
|
||||||
s9_item1: access your data and obtain a copy of it;
|
s9_item1: access your data and obtain a copy of it;
|
||||||
@@ -84,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
|
||||||
@@ -165,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
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ es:
|
|||||||
s3_item4_html: "<strong>Contenidos audiovisuales:</strong> flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
|
s3_item4_html: "<strong>Contenidos audiovisuales:</strong> flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
|
||||||
s3_item5_html: "<strong>Datos de pago:</strong> para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
|
s3_item5_html: "<strong>Datos de pago:</strong> para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
|
||||||
s3_item6_html: "<strong>Datos de navegación:</strong> registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
|
s3_item6_html: "<strong>Datos de navegación:</strong> registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
|
||||||
|
s3_item7_html: "<strong>Datos del formulario de contacto:</strong> nombre, email, club opcional, motivo y mensaje enviados desde la página de Contacto."
|
||||||
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
|
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
|
||||||
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a <strong>deportistas menores de edad</strong> identificables (imagen, voz, número de camiseta, datos de juego)."
|
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a <strong>deportistas menores de edad</strong> identificables (imagen, voz, número de camiseta, datos de juego)."
|
||||||
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
|
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
|
||||||
@@ -56,6 +57,8 @@ es:
|
|||||||
s5_row5_basis: Ejecución del contrato
|
s5_row5_basis: Ejecución del contrato
|
||||||
s5_row6_purpose: Marketing directo (solo si está previsto y con consentimiento cuando se requiera)
|
s5_row6_purpose: Marketing directo (solo si está previsto y con consentimiento cuando se requiera)
|
||||||
s5_row6_basis: Consentimiento o interés legítimo, según los casos
|
s5_row6_basis: Consentimiento o interés legítimo, según los casos
|
||||||
|
s5_row7_purpose: Gestión de las solicitudes enviadas desde el formulario de contacto
|
||||||
|
s5_row7_basis: Medidas precontractuales; interés legítimo en responder a las solicitudes
|
||||||
s6_title: 6. Destinatarios y encargados del tratamiento
|
s6_title: 6. Destinatarios y encargados del tratamiento
|
||||||
s6_intro: "Los datos pueden comunicarse a proveedores que actúan como encargados del tratamiento, entre ellos:"
|
s6_intro: "Los datos pueden comunicarse a proveedores que actúan como encargados del tratamiento, entre ellos:"
|
||||||
s6_item1: proveedor de alojamiento e infraestructura en la nube/servidor;
|
s6_item1: proveedor de alojamiento e infraestructura en la nube/servidor;
|
||||||
@@ -72,6 +75,7 @@ es:
|
|||||||
s8_item2_html: "<strong>Grabaciones de partidos (repeticiones):</strong> según el plan (por ejemplo, 30 o 90 días), después eliminación automática o a petición."
|
s8_item2_html: "<strong>Grabaciones de partidos (repeticiones):</strong> según el plan (por ejemplo, 30 o 90 días), después eliminación automática o a petición."
|
||||||
s8_item3_html: "<strong>Registros técnicos:</strong> periodo limitado (por ejemplo, 30-90 días) salvo obligaciones de seguridad."
|
s8_item3_html: "<strong>Registros técnicos:</strong> periodo limitado (por ejemplo, 30-90 días) salvo obligaciones de seguridad."
|
||||||
s8_item4_html: "<strong>Token de restablecimiento de contraseña:</strong> validez máxima %{hours} horas."
|
s8_item4_html: "<strong>Token de restablecimiento de contraseña:</strong> validez máxima %{hours} horas."
|
||||||
|
s8_item5_html: "<strong>Solicitudes del formulario de contacto:</strong> se conservan solo el tiempo necesario para gestionar la solicitud y luego se eliminan del buzón operativo."
|
||||||
s9_title: 9. Derechos de los interesados
|
s9_title: 9. Derechos de los interesados
|
||||||
s9_intro: "Como interesado tienes derecho a:"
|
s9_intro: "Como interesado tienes derecho a:"
|
||||||
s9_item1: acceder a tus datos y obtener una copia de ellos;
|
s9_item1: acceder a tus datos y obtener una copia de ellos;
|
||||||
@@ -84,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
|
||||||
@@ -165,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
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ fr:
|
|||||||
s3_item4_html: "<strong>Contenus audiovisuels :</strong> flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
|
s3_item4_html: "<strong>Contenus audiovisuels :</strong> flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
|
||||||
s3_item5_html: "<strong>Données de paiement :</strong> pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
|
s3_item5_html: "<strong>Données de paiement :</strong> pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
|
||||||
s3_item6_html: "<strong>Données de navigation :</strong> journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
|
s3_item6_html: "<strong>Données de navigation :</strong> journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
|
||||||
|
s3_item7_html: "<strong>Données du formulaire de contact :</strong> nom, email, club éventuel, motif et message envoyés depuis la page Contact."
|
||||||
s4_title: 4. Traitement des images et données des athlètes mineurs
|
s4_title: 4. Traitement des images et données des athlètes mineurs
|
||||||
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des <strong>athlètes mineurs</strong> identifiables (image, voix, numéro de maillot, données de jeu)."
|
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des <strong>athlètes mineurs</strong> identifiables (image, voix, numéro de maillot, données de jeu)."
|
||||||
s4_lead1: Qui est responsable du traitement des images des mineurs
|
s4_lead1: Qui est responsable du traitement des images des mineurs
|
||||||
@@ -56,6 +57,8 @@ fr:
|
|||||||
s5_row5_basis: Exécution du contrat
|
s5_row5_basis: Exécution du contrat
|
||||||
s5_row6_purpose: Marketing direct (uniquement si prévu et avec consentement le cas échéant)
|
s5_row6_purpose: Marketing direct (uniquement si prévu et avec consentement le cas échéant)
|
||||||
s5_row6_basis: Consentement ou intérêt légitime, selon les cas
|
s5_row6_basis: Consentement ou intérêt légitime, selon les cas
|
||||||
|
s5_row7_purpose: Traitement des demandes envoyées via le formulaire de contact
|
||||||
|
s5_row7_basis: Mesures précontractuelles ; intérêt légitime à répondre aux demandes
|
||||||
s6_title: 6. Destinataires et sous-traitants
|
s6_title: 6. Destinataires et sous-traitants
|
||||||
s6_intro: "Les données peuvent être communiquées à des prestataires agissant en tant que sous-traitants, notamment :"
|
s6_intro: "Les données peuvent être communiquées à des prestataires agissant en tant que sous-traitants, notamment :"
|
||||||
s6_item1: fournisseur d'hébergement et d'infrastructure cloud/serveur ;
|
s6_item1: fournisseur d'hébergement et d'infrastructure cloud/serveur ;
|
||||||
@@ -72,6 +75,7 @@ fr:
|
|||||||
s8_item2_html: "<strong>Enregistrements des matchs (replays) :</strong> selon le forfait (par ex. 30 ou 90 jours), puis suppression automatique ou sur demande."
|
s8_item2_html: "<strong>Enregistrements des matchs (replays) :</strong> selon le forfait (par ex. 30 ou 90 jours), puis suppression automatique ou sur demande."
|
||||||
s8_item3_html: "<strong>Journaux techniques :</strong> durée limitée (par ex. 30 à 90 jours) sauf obligations de sécurité."
|
s8_item3_html: "<strong>Journaux techniques :</strong> durée limitée (par ex. 30 à 90 jours) sauf obligations de sécurité."
|
||||||
s8_item4_html: "<strong>Jeton de réinitialisation du mot de passe :</strong> validité maximale %{hours} heures."
|
s8_item4_html: "<strong>Jeton de réinitialisation du mot de passe :</strong> validité maximale %{hours} heures."
|
||||||
|
s8_item5_html: "<strong>Demandes du formulaire de contact :</strong> conservées uniquement le temps nécessaire au traitement de la demande, puis supprimées de la boîte mail opérationnelle."
|
||||||
s9_title: 9. Droits des personnes concernées
|
s9_title: 9. Droits des personnes concernées
|
||||||
s9_intro: "En tant que personne concernée, vous avez le droit :"
|
s9_intro: "En tant que personne concernée, vous avez le droit :"
|
||||||
s9_item1: d'accéder à vos données et d'en obtenir une copie ;
|
s9_item1: d'accéder à vos données et d'en obtenir une copie ;
|
||||||
@@ -84,7 +88,7 @@ fr:
|
|||||||
s10_title: 10. Sécurité
|
s10_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
|
||||||
@@ -165,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
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ it:
|
|||||||
s3_item4_html: "<strong>Contenuti audiovisivi:</strong> flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato."
|
s3_item4_html: "<strong>Contenuti audiovisivi:</strong> flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato."
|
||||||
s3_item5_html: "<strong>Dati di pagamento:</strong> per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta."
|
s3_item5_html: "<strong>Dati di pagamento:</strong> per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta."
|
||||||
s3_item6_html: "<strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
|
s3_item6_html: "<strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
|
||||||
|
s3_item7_html: "<strong>Dati del modulo contatti:</strong> nome, email, eventuale società, motivo e messaggio inviati dalla pagina Contatti."
|
||||||
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
|
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
|
||||||
s4_p1_html: "Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18). Le dirette possono riprendere <strong>atleti minorenni</strong> identificabili (immagine, voce, numero di maglia, dati di gioco)."
|
s4_p1_html: "Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18). Le dirette possono riprendere <strong>atleti minorenni</strong> identificabili (immagine, voce, numero di maglia, dati di gioco)."
|
||||||
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
|
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
|
||||||
@@ -56,6 +57,8 @@ it:
|
|||||||
s5_row5_basis: Esecuzione del contratto
|
s5_row5_basis: Esecuzione del contratto
|
||||||
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
|
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
|
||||||
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
|
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
|
||||||
|
s5_row7_purpose: Gestione delle richieste inviate dal modulo contatti
|
||||||
|
s5_row7_basis: Misure precontrattuali; legittimo interesse a rispondere alle richieste
|
||||||
s6_title: 6. Destinatari e responsabili del trattamento
|
s6_title: 6. Destinatari e responsabili del trattamento
|
||||||
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
|
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
|
||||||
s6_item1: fornitore di hosting e infrastruttura cloud/server;
|
s6_item1: fornitore di hosting e infrastruttura cloud/server;
|
||||||
@@ -72,6 +75,7 @@ it:
|
|||||||
s8_item2_html: "<strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta."
|
s8_item2_html: "<strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta."
|
||||||
s8_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 30–90 giorni) salvo obblighi di sicurezza."
|
s8_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 30–90 giorni) salvo obblighi di sicurezza."
|
||||||
s8_item4_html: "<strong>Token reset password:</strong> validità massima %{hours} ore."
|
s8_item4_html: "<strong>Token reset password:</strong> validità massima %{hours} ore."
|
||||||
|
s8_item5_html: "<strong>Richieste dal modulo contatti:</strong> conservate solo per il tempo necessario a gestire la richiesta, poi eliminate dalla casella di posta operativa."
|
||||||
s9_title: 9. Diritti degli interessati
|
s9_title: 9. Diritti degli interessati
|
||||||
s9_intro: "In qualità di interessato hai diritto di:"
|
s9_intro: "In qualità di interessato hai diritto di:"
|
||||||
s9_item1: accedere ai tuoi dati e ottenerne copia;
|
s9_item1: accedere ai tuoi dati e ottenerne copia;
|
||||||
@@ -84,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
|
||||||
@@ -165,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
|
||||||
@@ -181,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
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ de:
|
|||||||
expiry_html: "Der Link ist %{hours} Stunden gültig. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail — dein Passwort wird nicht geändert."
|
expiry_html: "Der Link ist %{hours} Stunden gültig. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail — dein Passwort wird nicht geändert."
|
||||||
ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.
|
ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
contact:
|
||||||
|
subject:
|
||||||
|
commercial: "Vertriebsanfrage — %{name}"
|
||||||
|
support: "Supportanfrage — %{name}"
|
||||||
|
privacy: "Datenschutzanfrage — %{name}"
|
||||||
|
heading: Neue Anfrage von der Website
|
||||||
|
name: "Name:"
|
||||||
|
email: "E-Mail:"
|
||||||
|
club: "Verein:"
|
||||||
|
club_none: Nicht angegeben
|
||||||
|
topic: "Anliegen:"
|
||||||
|
topic_labels:
|
||||||
|
commercial: Vertrieb / Angebot
|
||||||
|
support: Technischer Support
|
||||||
|
privacy: Datenschutz / DSGVO
|
||||||
|
message: "Nachricht:"
|
||||||
|
footer: Match Live TV — Kontaktseite
|
||||||
replay_ready:
|
replay_ready:
|
||||||
subject: "Replay bereit — %{title}"
|
subject: "Replay bereit — %{title}"
|
||||||
hello: "Hallo %{name},"
|
hello: "Hallo %{name},"
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ en:
|
|||||||
expiry_html: "The link is valid for %{hours} hours. If you did not request this reset, ignore this email — your password will not change."
|
expiry_html: "The link is valid for %{hours} hours. If you did not request this reset, ignore this email — your password will not change."
|
||||||
ignore: If you did not request this reset, ignore this email.
|
ignore: If you did not request this reset, ignore this email.
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
contact:
|
||||||
|
subject:
|
||||||
|
commercial: "Sales enquiry — %{name}"
|
||||||
|
support: "Support enquiry — %{name}"
|
||||||
|
privacy: "Privacy enquiry — %{name}"
|
||||||
|
heading: New website enquiry
|
||||||
|
name: "Name:"
|
||||||
|
email: "Email:"
|
||||||
|
club: "Club:"
|
||||||
|
club_none: Not provided
|
||||||
|
topic: "Topic:"
|
||||||
|
topic_labels:
|
||||||
|
commercial: Sales / quote
|
||||||
|
support: Technical support
|
||||||
|
privacy: Privacy / GDPR
|
||||||
|
message: "Message:"
|
||||||
|
footer: Match Live TV — Contact page
|
||||||
replay_ready:
|
replay_ready:
|
||||||
subject: "Replay ready — %{title}"
|
subject: "Replay ready — %{title}"
|
||||||
hello: "Hi %{name},"
|
hello: "Hi %{name},"
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ es:
|
|||||||
expiry_html: "El enlace es válido durante %{hours} horas. Si no solicitaste el restablecimiento, ignora este correo: la contraseña no se modificará."
|
expiry_html: "El enlace es válido durante %{hours} horas. Si no solicitaste el restablecimiento, ignora este correo: la contraseña no se modificará."
|
||||||
ignore: Si no solicitaste el restablecimiento, ignora este correo.
|
ignore: Si no solicitaste el restablecimiento, ignora este correo.
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
contact:
|
||||||
|
subject:
|
||||||
|
commercial: "Consulta comercial — %{name}"
|
||||||
|
support: "Consulta de asistencia — %{name}"
|
||||||
|
privacy: "Consulta de privacidad — %{name}"
|
||||||
|
heading: Nueva solicitud desde el sitio
|
||||||
|
name: "Nombre:"
|
||||||
|
email: "Email:"
|
||||||
|
club: "Club:"
|
||||||
|
club_none: No indicado
|
||||||
|
topic: "Motivo:"
|
||||||
|
topic_labels:
|
||||||
|
commercial: Comercial / presupuesto
|
||||||
|
support: Asistencia técnica
|
||||||
|
privacy: Privacidad / RGPD
|
||||||
|
message: "Mensaje:"
|
||||||
|
footer: Match Live TV — página de Contacto
|
||||||
replay_ready:
|
replay_ready:
|
||||||
subject: "Replay listo — %{title}"
|
subject: "Replay listo — %{title}"
|
||||||
hello: "Hola %{name},"
|
hello: "Hola %{name},"
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ fr:
|
|||||||
expiry_html: "Le lien est valable %{hours} heures. Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié."
|
expiry_html: "Le lien est valable %{hours} heures. Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié."
|
||||||
ignore: Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail.
|
ignore: Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail.
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
contact:
|
||||||
|
subject:
|
||||||
|
commercial: "Demande commerciale — %{name}"
|
||||||
|
support: "Demande d'assistance — %{name}"
|
||||||
|
privacy: "Demande privacy — %{name}"
|
||||||
|
heading: Nouvelle demande depuis le site
|
||||||
|
name: "Nom :"
|
||||||
|
email: "Email :"
|
||||||
|
club: "Club :"
|
||||||
|
club_none: Non indiqué
|
||||||
|
topic: "Motif :"
|
||||||
|
topic_labels:
|
||||||
|
commercial: Commercial / devis
|
||||||
|
support: Assistance technique
|
||||||
|
privacy: Confidentialité / RGPD
|
||||||
|
message: "Message :"
|
||||||
|
footer: Match Live TV — page Contact
|
||||||
replay_ready:
|
replay_ready:
|
||||||
subject: "Replay prêt — %{title}"
|
subject: "Replay prêt — %{title}"
|
||||||
hello: "Bonjour %{name},"
|
hello: "Bonjour %{name},"
|
||||||
|
|||||||
@@ -9,6 +9,23 @@ it:
|
|||||||
expiry_html: "Il link è valido per %{hours} ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata."
|
expiry_html: "Il link è valido per %{hours} ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata."
|
||||||
ignore: Se non hai richiesto tu il reset, ignora questa email.
|
ignore: Se non hai richiesto tu il reset, ignora questa email.
|
||||||
footer: "Match Live TV — %{email}"
|
footer: "Match Live TV — %{email}"
|
||||||
|
contact:
|
||||||
|
subject:
|
||||||
|
commercial: "Richiesta commerciale — %{name}"
|
||||||
|
support: "Richiesta assistenza — %{name}"
|
||||||
|
privacy: "Richiesta privacy — %{name}"
|
||||||
|
heading: Nuova richiesta dal sito
|
||||||
|
name: "Nome:"
|
||||||
|
email: "Email:"
|
||||||
|
club: "Società:"
|
||||||
|
club_none: Non indicata
|
||||||
|
topic: "Motivo:"
|
||||||
|
topic_labels:
|
||||||
|
commercial: Commerciale / preventivo
|
||||||
|
support: Assistenza tecnica
|
||||||
|
privacy: Privacy / GDPR
|
||||||
|
message: "Messaggio:"
|
||||||
|
footer: Match Live TV — pagina Contatti
|
||||||
replay_ready:
|
replay_ready:
|
||||||
subject: "Replay pronto — %{title}"
|
subject: "Replay pronto — %{title}"
|
||||||
hello: "Ciao %{name},"
|
hello: "Ciao %{name},"
|
||||||
|
|||||||
@@ -175,3 +175,41 @@ de:
|
|||||||
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
|
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
|
||||||
cta_start: Kostenlos starten
|
cta_start: Kostenlos starten
|
||||||
cta_faq: Häufige Fragen
|
cta_faq: Häufige Fragen
|
||||||
|
contacts:
|
||||||
|
meta_title: "Kontakt — Match Live TV"
|
||||||
|
meta_description: "Kontakt zu Match Live TV: Angebote für Sportvereine, technischer Support und Datenschutzanfragen. Sitz in Mailand."
|
||||||
|
eyebrow: Match Live TV
|
||||||
|
title: Kontakt
|
||||||
|
lead: Schreib uns für ein Angebot, App-Support oder eine Datenschutzanfrage. Wir antworten in der Regel innerhalb eines Werktags.
|
||||||
|
response_time: "Typische Antwortzeit: 1 Werktag"
|
||||||
|
commercial_title: Vertrieb
|
||||||
|
commercial_body: Maßgeschneiderte Tarife, mehrere Teams, Turniere oder Integrationen. Schreib an
|
||||||
|
support_title: Support
|
||||||
|
support_body_html: "Probleme mit Zugang, Livestream oder App. Sieh zuerst die %{faq_link} oder die Seite %{support_link} an oder schreib an"
|
||||||
|
support_link: Support
|
||||||
|
faq_link: FAQ
|
||||||
|
privacy_title: Datenschutz
|
||||||
|
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
|
||||||
|
company_title: Sitz
|
||||||
|
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
|
||||||
|
social_title: Folgen Sie uns auch in den sozialen Medien
|
||||||
|
label_address: Adresse
|
||||||
|
label_vat: USt-IdNr.
|
||||||
|
form_title: Nachricht senden
|
||||||
|
form_lead: "Fülle das Formular aus: Wir antworten an die angegebene E-Mail-Adresse."
|
||||||
|
name_label: Name
|
||||||
|
email_label: E-Mail
|
||||||
|
club_label: Sportverein (optional)
|
||||||
|
topic_label: Anliegen
|
||||||
|
topic_commercial: Vertrieb / Angebot
|
||||||
|
topic_support: Technischer Support
|
||||||
|
topic_privacy: Datenschutz / DSGVO
|
||||||
|
message_label: Nachricht
|
||||||
|
honeypot_label: Website
|
||||||
|
privacy_accept_html: Ich stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu, wie in der %{privacy_link} beschrieben.
|
||||||
|
privacy_link: Datenschutzerklärung
|
||||||
|
submit: Nachricht senden
|
||||||
|
cta_title: Lieber selbst nachsehen?
|
||||||
|
cta_body: Viele Antworten stehen bereits in den FAQ. Um mit dem Streamen zu starten, registriere den Verein.
|
||||||
|
cta_faq: FAQ
|
||||||
|
cta_signup: Team registrieren
|
||||||
|
|||||||
@@ -175,3 +175,41 @@ en:
|
|||||||
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
|
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
|
||||||
cta_start: Start for free
|
cta_start: Start for free
|
||||||
cta_faq: Frequently asked questions
|
cta_faq: Frequently asked questions
|
||||||
|
contacts:
|
||||||
|
meta_title: "Contact — Match Live TV"
|
||||||
|
meta_description: "Contact Match Live TV: quotes for sports clubs, technical support and privacy requests. Based in Milan."
|
||||||
|
eyebrow: Match Live TV
|
||||||
|
title: Contact
|
||||||
|
lead: Write to us for a quote, app support or a privacy request. We usually reply within one working day.
|
||||||
|
response_time: "Typical reply time: 1 working day"
|
||||||
|
commercial_title: Sales
|
||||||
|
commercial_body: Custom plans, multiple teams, tournaments or integrations. Write to
|
||||||
|
support_title: Support
|
||||||
|
support_body_html: "Sign-in, live stream or app issues. Check the %{faq_link} or the %{support_link} page first, or write to"
|
||||||
|
support_link: Support
|
||||||
|
faq_link: FAQ
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body: GDPR rights, consent and personal data. Write to
|
||||||
|
company_title: Company details
|
||||||
|
company_lead: Service provider and data controller.
|
||||||
|
social_title: Follow us on social media
|
||||||
|
label_address: Address
|
||||||
|
label_vat: VAT number
|
||||||
|
form_title: Send a message
|
||||||
|
form_lead: "Fill in the form and we will reply to the email you provide."
|
||||||
|
name_label: Name
|
||||||
|
email_label: Email
|
||||||
|
club_label: Sports club (optional)
|
||||||
|
topic_label: Topic
|
||||||
|
topic_commercial: Sales / quote
|
||||||
|
topic_support: Technical support
|
||||||
|
topic_privacy: Privacy / GDPR
|
||||||
|
message_label: Message
|
||||||
|
honeypot_label: Website
|
||||||
|
privacy_accept_html: I agree to the processing of my data to handle this request, as described in the %{privacy_link}.
|
||||||
|
privacy_link: privacy notice
|
||||||
|
submit: Send message
|
||||||
|
cta_title: Prefer to look it up yourself?
|
||||||
|
cta_body: Many answers are already in the FAQ. To start streaming, register your club.
|
||||||
|
cta_faq: FAQ
|
||||||
|
cta_signup: Register your team
|
||||||
|
|||||||
@@ -175,3 +175,41 @@ es:
|
|||||||
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
|
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
|
||||||
cta_start: Empieza gratis
|
cta_start: Empieza gratis
|
||||||
cta_faq: Preguntas frecuentes
|
cta_faq: Preguntas frecuentes
|
||||||
|
contacts:
|
||||||
|
meta_title: "Contacto — Match Live TV"
|
||||||
|
meta_description: "Contacta con Match Live TV: presupuestos para clubes deportivos, soporte técnico y solicitudes de privacidad. Sede en Milán."
|
||||||
|
eyebrow: Match Live TV
|
||||||
|
title: Contacto
|
||||||
|
lead: Escríbenos para un presupuesto, para ayuda con la app o para una solicitud de privacidad. Solemos responder en un día laborable.
|
||||||
|
response_time: "Tiempo de respuesta orientativo: 1 día laborable"
|
||||||
|
commercial_title: Comercial
|
||||||
|
commercial_body: Planes a medida, varios equipos, torneos o integraciones. Escribe a
|
||||||
|
support_title: Asistencia
|
||||||
|
support_body_html: "Problemas de acceso, directos o app. Consulta primero las %{faq_link} o la página %{support_link}, o escribe a"
|
||||||
|
support_link: Soporte
|
||||||
|
faq_link: FAQ
|
||||||
|
privacy_title: Privacidad
|
||||||
|
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
|
||||||
|
company_title: Sede
|
||||||
|
company_lead: Prestador del servicio y responsable del tratamiento.
|
||||||
|
social_title: Síguenos también en redes sociales
|
||||||
|
label_address: Dirección
|
||||||
|
label_vat: NIF / IVA
|
||||||
|
form_title: Enviar un mensaje
|
||||||
|
form_lead: "Completa el formulario: te responderemos al email que indiques."
|
||||||
|
name_label: Nombre
|
||||||
|
email_label: Email
|
||||||
|
club_label: Club deportivo (opcional)
|
||||||
|
topic_label: Motivo
|
||||||
|
topic_commercial: Comercial / presupuesto
|
||||||
|
topic_support: Asistencia técnica
|
||||||
|
topic_privacy: Privacidad / RGPD
|
||||||
|
message_label: Mensaje
|
||||||
|
honeypot_label: Sitio web
|
||||||
|
privacy_accept_html: Acepto el tratamiento de los datos para gestionar esta solicitud, como se describe en la %{privacy_link}.
|
||||||
|
privacy_link: política de privacidad
|
||||||
|
submit: Enviar mensaje
|
||||||
|
cta_title: "¿Prefieres buscarlo tú?"
|
||||||
|
cta_body: Muchas respuestas ya están en las FAQ. Para empezar a emitir, registra el club.
|
||||||
|
cta_faq: FAQ
|
||||||
|
cta_signup: Registrar el equipo
|
||||||
|
|||||||
@@ -175,3 +175,41 @@ fr:
|
|||||||
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
|
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
|
||||||
cta_start: Commencer gratuitement
|
cta_start: Commencer gratuitement
|
||||||
cta_faq: Questions fréquentes
|
cta_faq: Questions fréquentes
|
||||||
|
contacts:
|
||||||
|
meta_title: "Contact — Match Live TV"
|
||||||
|
meta_description: "Contactez Match Live TV : devis pour clubs sportifs, assistance technique et demandes privacy. Siège à Milan."
|
||||||
|
eyebrow: Match Live TV
|
||||||
|
title: Contact
|
||||||
|
lead: Écrivez-nous pour un devis, pour l'assistance de l'app ou pour une demande privacy. Nous répondons en général sous un jour ouvrable.
|
||||||
|
response_time: "Délai indicatif : 1 jour ouvrable"
|
||||||
|
commercial_title: Commercial
|
||||||
|
commercial_body: Offres sur mesure, plusieurs équipes, tournois ou intégrations. Écrivez à
|
||||||
|
support_title: Assistance
|
||||||
|
support_body_html: "Problèmes d'accès, de direct ou d'application. Consultez d'abord les %{faq_link} ou la page %{support_link}, ou écrivez à"
|
||||||
|
support_link: Support
|
||||||
|
faq_link: FAQ
|
||||||
|
privacy_title: Confidentialité
|
||||||
|
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
|
||||||
|
company_title: Siège
|
||||||
|
company_lead: Prestataire du service et responsable du traitement.
|
||||||
|
social_title: Suivez-nous aussi sur les réseaux sociaux
|
||||||
|
label_address: Adresse
|
||||||
|
label_vat: N° de TVA
|
||||||
|
form_title: Envoyer un message
|
||||||
|
form_lead: "Remplissez le formulaire : nous vous répondons à l'email indiqué."
|
||||||
|
name_label: Nom
|
||||||
|
email_label: Email
|
||||||
|
club_label: Club sportif (facultatif)
|
||||||
|
topic_label: Motif
|
||||||
|
topic_commercial: Commercial / devis
|
||||||
|
topic_support: Assistance technique
|
||||||
|
topic_privacy: Confidentialité / RGPD
|
||||||
|
message_label: Message
|
||||||
|
honeypot_label: Site web
|
||||||
|
privacy_accept_html: J'accepte le traitement des données pour gérer cette demande, comme décrit dans la %{privacy_link}.
|
||||||
|
privacy_link: notice de confidentialité
|
||||||
|
submit: Envoyer le message
|
||||||
|
cta_title: Vous préférez chercher vous-même ?
|
||||||
|
cta_body: Beaucoup de réponses sont déjà dans la FAQ. Pour commencer à diffuser, inscrivez le club.
|
||||||
|
cta_faq: FAQ
|
||||||
|
cta_signup: Enregistrer l'équipe
|
||||||
|
|||||||
@@ -175,3 +175,41 @@ it:
|
|||||||
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
|
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
|
||||||
cta_start: Inizia gratis
|
cta_start: Inizia gratis
|
||||||
cta_faq: Domande frequenti
|
cta_faq: Domande frequenti
|
||||||
|
contacts:
|
||||||
|
meta_title: "Contatti — Match Live TV"
|
||||||
|
meta_description: "Contatta Match Live TV: preventivi per società sportive, assistenza tecnica e richieste privacy. Sede a Milano."
|
||||||
|
eyebrow: Match Live TV
|
||||||
|
title: Contatti
|
||||||
|
lead: Scrivici per un preventivo, per l'assistenza sull'app o per una richiesta privacy. Rispondiamo in genere entro un giorno lavorativo.
|
||||||
|
response_time: "Risposta indicativa: 1 giorno lavorativo"
|
||||||
|
commercial_title: Commerciale
|
||||||
|
commercial_body: Piani su misura, più squadre, tornei o integrazioni. Scrivi a
|
||||||
|
support_title: Assistenza
|
||||||
|
support_body_html: "Problemi di accesso, dirette o app. Consulta prima le %{faq_link} o la pagina %{support_link}, oppure scrivi a"
|
||||||
|
support_link: Supporto
|
||||||
|
faq_link: FAQ
|
||||||
|
privacy_title: Privacy
|
||||||
|
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
|
||||||
|
company_title: Sede
|
||||||
|
company_lead: Titolare del servizio e del trattamento dei dati.
|
||||||
|
social_title: Seguici anche sui social
|
||||||
|
label_address: Indirizzo
|
||||||
|
label_vat: Partita IVA
|
||||||
|
form_title: Invia un messaggio
|
||||||
|
form_lead: "Compila il modulo: ti rispondiamo all'email che indichi."
|
||||||
|
name_label: Nome
|
||||||
|
email_label: Email
|
||||||
|
club_label: Società sportiva (opzionale)
|
||||||
|
topic_label: Motivo
|
||||||
|
topic_commercial: Commerciale / preventivo
|
||||||
|
topic_support: Assistenza tecnica
|
||||||
|
topic_privacy: Privacy / GDPR
|
||||||
|
message_label: Messaggio
|
||||||
|
honeypot_label: Sito web
|
||||||
|
privacy_accept_html: Acconsento al trattamento dei dati per gestire questa richiesta, come descritto nella %{privacy_link}.
|
||||||
|
privacy_link: informativa privacy
|
||||||
|
submit: Invia messaggio
|
||||||
|
cta_title: Preferisci fare da solo?
|
||||||
|
cta_body: Molte risposte sono già nelle FAQ. Per iniziare a trasmettere, registra la società.
|
||||||
|
cta_faq: FAQ
|
||||||
|
cta_signup: Registra la squadra
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ de:
|
|||||||
manage_cookies: Cookies verwalten
|
manage_cookies: Cookies verwalten
|
||||||
copyright: "© 2026 Emiliano Frascaro – USt-IdNr. 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – USt-IdNr. 14230270960"
|
||||||
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
|
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
|
||||||
|
social_nav: MatchLiveTV Social-Profile
|
||||||
|
social_facebook: Facebook MatchLiveTV
|
||||||
|
social_instagram: Instagram MatchLiveTV
|
||||||
|
social_tiktok: TikTok MatchLiveTV
|
||||||
|
social_youtube: YouTube MatchLiveTV
|
||||||
cookie:
|
cookie:
|
||||||
title: Cookies und Datenschutz
|
title: Cookies und Datenschutz
|
||||||
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
|
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
|
||||||
@@ -141,6 +146,7 @@ de:
|
|||||||
common:
|
common:
|
||||||
privacy: Datenschutz
|
privacy: Datenschutz
|
||||||
support: Support
|
support: Support
|
||||||
|
contacts: Kontakt
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: AGB
|
terms: AGB
|
||||||
pricing: Preise
|
pricing: Preise
|
||||||
@@ -174,3 +180,7 @@ de:
|
|||||||
choice_youtube_hint_only: Entfernt nur das YouTube-Video
|
choice_youtube_hint_only: Entfernt nur das YouTube-Video
|
||||||
choice_both_label: Website und YouTube
|
choice_both_label: Website und YouTube
|
||||||
choice_both_hint: Vollständige Löschung
|
choice_both_hint: Vollständige Löschung
|
||||||
|
announcement:
|
||||||
|
region: Hinweise
|
||||||
|
dismiss: Hinweis schließen
|
||||||
|
open: Öffnen
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ en:
|
|||||||
manage_cookies: Manage cookies
|
manage_cookies: Manage cookies
|
||||||
copyright: "© 2026 Emiliano Frascaro – VAT 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – VAT 14230270960"
|
||||||
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
|
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
|
||||||
|
social_nav: MatchLiveTV social profiles
|
||||||
|
social_facebook: Facebook MatchLiveTV
|
||||||
|
social_instagram: Instagram MatchLiveTV
|
||||||
|
social_tiktok: TikTok MatchLiveTV
|
||||||
|
social_youtube: YouTube MatchLiveTV
|
||||||
cookie:
|
cookie:
|
||||||
title: Cookies and privacy
|
title: Cookies and privacy
|
||||||
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
|
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
|
||||||
@@ -141,6 +146,7 @@ en:
|
|||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
support: Support
|
support: Support
|
||||||
|
contacts: Contacts
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Terms
|
terms: Terms
|
||||||
pricing: Pricing
|
pricing: Pricing
|
||||||
@@ -174,3 +180,7 @@ en:
|
|||||||
choice_youtube_hint_only: Removes only the YouTube video
|
choice_youtube_hint_only: Removes only the YouTube video
|
||||||
choice_both_label: Site and YouTube
|
choice_both_label: Site and YouTube
|
||||||
choice_both_hint: Complete deletion
|
choice_both_hint: Complete deletion
|
||||||
|
announcement:
|
||||||
|
region: Notices
|
||||||
|
dismiss: Dismiss notice
|
||||||
|
open: Open
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ es:
|
|||||||
manage_cookies: Gestionar cookies
|
manage_cookies: Gestionar cookies
|
||||||
copyright: "© 2026 Emiliano Frascaro – NIF 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – NIF 14230270960"
|
||||||
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
|
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
|
||||||
|
social_nav: Perfiles sociales MatchLiveTV
|
||||||
|
social_facebook: Facebook MatchLiveTV
|
||||||
|
social_instagram: Instagram MatchLiveTV
|
||||||
|
social_tiktok: TikTok MatchLiveTV
|
||||||
|
social_youtube: YouTube MatchLiveTV
|
||||||
cookie:
|
cookie:
|
||||||
title: Cookies y privacidad
|
title: Cookies y privacidad
|
||||||
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
|
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
|
||||||
@@ -141,6 +146,7 @@ es:
|
|||||||
common:
|
common:
|
||||||
privacy: Privacidad
|
privacy: Privacidad
|
||||||
support: Soporte
|
support: Soporte
|
||||||
|
contacts: Contacto
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Términos
|
terms: Términos
|
||||||
pricing: Precios
|
pricing: Precios
|
||||||
@@ -174,3 +180,7 @@ es:
|
|||||||
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
|
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
|
||||||
choice_both_label: Sitio y YouTube
|
choice_both_label: Sitio y YouTube
|
||||||
choice_both_hint: Eliminación completa
|
choice_both_hint: Eliminación completa
|
||||||
|
announcement:
|
||||||
|
region: Avisos
|
||||||
|
dismiss: Cerrar aviso
|
||||||
|
open: Abrir
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ fr:
|
|||||||
manage_cookies: Gérer les cookies
|
manage_cookies: Gérer les cookies
|
||||||
copyright: "© 2026 Emiliano Frascaro – TVA 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – TVA 14230270960"
|
||||||
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
|
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
|
||||||
|
social_nav: Profils sociaux MatchLiveTV
|
||||||
|
social_facebook: Facebook MatchLiveTV
|
||||||
|
social_instagram: Instagram MatchLiveTV
|
||||||
|
social_tiktok: TikTok MatchLiveTV
|
||||||
|
social_youtube: YouTube MatchLiveTV
|
||||||
cookie:
|
cookie:
|
||||||
title: Cookies et confidentialité
|
title: Cookies et confidentialité
|
||||||
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
|
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
|
||||||
@@ -141,6 +146,7 @@ fr:
|
|||||||
common:
|
common:
|
||||||
privacy: Confidentialité
|
privacy: Confidentialité
|
||||||
support: Support
|
support: Support
|
||||||
|
contacts: Contacts
|
||||||
cookies: Cookies
|
cookies: Cookies
|
||||||
terms: Conditions
|
terms: Conditions
|
||||||
pricing: Tarifs
|
pricing: Tarifs
|
||||||
@@ -174,3 +180,7 @@ fr:
|
|||||||
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
|
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
|
||||||
choice_both_label: Site et YouTube
|
choice_both_label: Site et YouTube
|
||||||
choice_both_hint: Suppression complète
|
choice_both_hint: Suppression complète
|
||||||
|
announcement:
|
||||||
|
region: Alertes
|
||||||
|
dismiss: Fermer l'alerte
|
||||||
|
open: Ouvrir
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ it:
|
|||||||
manage_cookies: Gestisci cookie
|
manage_cookies: Gestisci cookie
|
||||||
copyright: "© 2026 Emiliano Frascaro – P. IVA 14230270960"
|
copyright: "© 2026 Emiliano Frascaro – P. IVA 14230270960"
|
||||||
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
|
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
|
||||||
|
social_nav: Profili social MatchLiveTV
|
||||||
|
social_facebook: Facebook MatchLiveTV
|
||||||
|
social_instagram: Instagram MatchLiveTV
|
||||||
|
social_tiktok: TikTok MatchLiveTV
|
||||||
|
social_youtube: YouTube MatchLiveTV
|
||||||
cookie:
|
cookie:
|
||||||
title: Cookie e privacy
|
title: Cookie e privacy
|
||||||
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
|
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
|
||||||
@@ -141,6 +146,7 @@ it:
|
|||||||
common:
|
common:
|
||||||
privacy: Privacy
|
privacy: Privacy
|
||||||
support: Supporto
|
support: Supporto
|
||||||
|
contacts: Contatti
|
||||||
cookies: Cookie
|
cookies: Cookie
|
||||||
terms: Termini
|
terms: Termini
|
||||||
pricing: Prezzi
|
pricing: Prezzi
|
||||||
@@ -174,3 +180,7 @@ it:
|
|||||||
choice_youtube_hint_only: Rimuove solo il video YouTube
|
choice_youtube_hint_only: Rimuove solo il video YouTube
|
||||||
choice_both_label: Da sito e YouTube
|
choice_both_label: Da sito e YouTube
|
||||||
choice_both_hint: Eliminazione completa
|
choice_both_hint: Eliminazione completa
|
||||||
|
announcement:
|
||||||
|
region: Avvisi
|
||||||
|
dismiss: Chiudi avviso
|
||||||
|
open: Apri
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ Rails.application.routes.draw do
|
|||||||
patch "account", to: "accounts#update"
|
patch "account", to: "accounts#update"
|
||||||
patch "account/password", to: "accounts#password"
|
patch "account/password", to: "accounts#password"
|
||||||
get "sports", to: "sports#index"
|
get "sports", to: "sports#index"
|
||||||
|
get "announcements", to: "announcements#index"
|
||||||
|
|
||||||
get "invitations/:token", to: "invitations#show"
|
get "invitations/:token", to: "invitations#show"
|
||||||
post "invitations/:token/accept", to: "invitations#accept"
|
post "invitations/:token/accept", to: "invitations#accept"
|
||||||
@@ -75,6 +76,9 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
post "internal/validate_publish", to: "webhooks/mediamtx#validate_publish"
|
post "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"
|
||||||
@@ -84,6 +88,7 @@ Rails.application.routes.draw do
|
|||||||
|
|
||||||
root to: "dashboard#index"
|
root to: "dashboard#index"
|
||||||
get "metrics", to: "dashboard#metrics"
|
get "metrics", to: "dashboard#metrics"
|
||||||
|
resources :announcements, except: %i[show]
|
||||||
resources :ops, only: [:index], controller: "ops" do
|
resources :ops, only: [:index], controller: "ops" do
|
||||||
member do
|
member do
|
||||||
post :acknowledge
|
post :acknowledge
|
||||||
@@ -125,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
|
||||||
|
|
||||||
@@ -170,6 +177,9 @@ Rails.application.routes.draw do
|
|||||||
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
|
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
|
||||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||||
get "pricing", to: redirect("/prezzi")
|
get "pricing", to: redirect("/prezzi")
|
||||||
|
get "contatti", to: "contacts#show", as: :contatti
|
||||||
|
post "contatti", to: "contacts#create"
|
||||||
|
get "contact", to: redirect("/contatti")
|
||||||
get "privacy", to: "pages#privacy", as: :privacy
|
get "privacy", to: "pages#privacy", as: :privacy
|
||||||
get "support", to: "pages#support", as: :support
|
get "support", to: "pages#support", as: :support
|
||||||
get "cookie", to: "pages#cookies", as: :cookies
|
get "cookie", to: "pages#cookies", as: :cookies
|
||||||
@@ -179,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,25 @@
|
|||||||
|
class CreateAppAnnouncements < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :app_announcements, id: :uuid do |t|
|
||||||
|
t.string :kind, null: false, default: "info"
|
||||||
|
t.string :severity, null: false, default: "info"
|
||||||
|
t.string :status, null: false, default: "draft"
|
||||||
|
t.boolean :show_on_app, null: false, default: true
|
||||||
|
t.boolean :show_on_web_public, null: false, default: false
|
||||||
|
t.boolean :show_on_web_private, null: false, default: false
|
||||||
|
t.string :title, null: false
|
||||||
|
t.text :body, null: false
|
||||||
|
t.string :action_url
|
||||||
|
t.string :action_label
|
||||||
|
t.boolean :dismissible, null: false, default: true
|
||||||
|
t.datetime :starts_at
|
||||||
|
t.datetime :ends_at
|
||||||
|
t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :app_announcements, :status
|
||||||
|
add_index :app_announcements, %i[status starts_at ends_at]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
class SplitAnnouncementAppPlatforms < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :app_announcements, :show_on_android, :boolean, null: false, default: true
|
||||||
|
add_column :app_announcements, :show_on_ios, :boolean, null: false, default: true
|
||||||
|
add_column :app_announcements, :android_title, :string
|
||||||
|
add_column :app_announcements, :android_body, :text
|
||||||
|
add_column :app_announcements, :android_action_url, :string
|
||||||
|
add_column :app_announcements, :android_action_label, :string
|
||||||
|
add_column :app_announcements, :ios_title, :string
|
||||||
|
add_column :app_announcements, :ios_body, :text
|
||||||
|
add_column :app_announcements, :ios_action_url, :string
|
||||||
|
add_column :app_announcements, :ios_action_label, :string
|
||||||
|
|
||||||
|
reversible do |dir|
|
||||||
|
dir.up do
|
||||||
|
execute <<~SQL.squish
|
||||||
|
UPDATE app_announcements
|
||||||
|
SET show_on_android = show_on_app,
|
||||||
|
show_on_ios = show_on_app
|
||||||
|
SQL
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
remove_column :app_announcements, :show_on_app, :boolean, null: false, default: true
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAnnouncementTranslations < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :app_announcements, :translations, :jsonb, null: false, default: {}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
class RemoveAnnouncementPlatformCopy < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
remove_column :app_announcements, :android_title, :string
|
||||||
|
remove_column :app_announcements, :android_body, :text
|
||||||
|
remove_column :app_announcements, :android_action_url, :string
|
||||||
|
remove_column :app_announcements, :android_action_label, :string
|
||||||
|
remove_column :app_announcements, :ios_title, :string
|
||||||
|
remove_column :app_announcements, :ios_body, :text
|
||||||
|
remove_column :app_announcements, :ios_action_url, :string
|
||||||
|
remove_column :app_announcements, :ios_action_label, :string
|
||||||
|
end
|
||||||
|
end
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user