Limita una diretta attiva per account e valorizza la copertina Premium Full.

Blocca la seconda diretta sullo stesso utente, registra gli abusi in admin e presenta la copertina come grafica caricata dalla società, con demo Team MLTV.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-01 12:52:26 +02:00
co-authored by Cursor
parent 356aa28fad
commit d52434fb2e
62 changed files with 1509 additions and 109 deletions
@@ -12,6 +12,7 @@ module Admin
@plans = Plan.ordered.reject { |p| p.slug == "free" } @plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name) @teams = @club.teams.order(:name)
@quote = @club.active_billing_quote @quote = @club.active_billing_quote
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
end end
def grant_comped def grant_comped
@@ -8,6 +8,8 @@ module Admin
.includes(:stream_node, 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)
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
end end
def metrics def metrics
@@ -18,6 +18,7 @@ module Admin
.find(params[:id]) .find(params[:id])
@events = @session.stream_events.recent.limit(100) @events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club @club = @session.match.team.club
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
end end
def stop def stop
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module Admin
class StreamConcurrencyViolationsController < Admin::BaseController
def index
@filters = {
q: params[:q].to_s.strip.presence,
devices_differ: params[:devices_differ].to_s == "1"
}
scope = StreamConcurrencyViolation.recent
if @filters[:q]
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
scope = scope.where(
"user_email ILIKE :term OR user_name ILIKE :term OR occupying_club_name ILIKE :term OR attempted_club_name ILIKE :term OR occupying_match_label ILIKE :term OR attempted_match_label ILIKE :term",
term: term
)
end
scope = scope.two_devices if @filters[:devices_differ]
@total_count = scope.count
@violations = scope.limit(200)
@lookback_count = StreamConcurrencyViolation.lookback.count
@two_devices_count = StreamConcurrencyViolation.lookback.two_devices.count
end
end
end
+4
View File
@@ -188,4 +188,8 @@ module AdminHelper
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") } labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash") labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end end
def admin_concurrency_violation_lookback_count
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
end
end end
@@ -0,0 +1,71 @@
# frozen_string_literal: true
class StreamConcurrencyViolation < ApplicationRecord
LOOKBACK = 30.days
belongs_to :user, optional: true
belongs_to :occupying_session, class_name: "StreamSession", optional: true
belongs_to :attempted_session, class_name: "StreamSession", optional: true
belongs_to :occupying_club, class_name: "Club", optional: true
belongs_to :attempted_club, class_name: "Club", optional: true
validates :user_email, :occupying_match_label, :attempted_match_label, presence: true
validates :attempt_action, inclusion: { in: %w[start resume] }
scope :recent, -> { order(created_at: :desc) }
scope :since, ->(time) { where("created_at >= ?", time) }
scope :lookback, -> { since(LOOKBACK.ago) }
scope :two_devices, -> { where(devices_differ: true) }
scope :for_club, lambda { |club_id|
where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id)
}
scope :for_session, lambda { |session_id|
where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id)
}
def self.record!(attempted:, occupying:, action: "start")
user = attempted.user || occupying.user
occupying_club = occupying.match&.team&.club
attempted_club = attempted.match&.team&.club
occupying_device = occupying.client_device_label
attempted_device = attempted.client_device_label
create!(
user: user,
occupying_session: occupying,
attempted_session: attempted,
occupying_club: occupying_club,
attempted_club: attempted_club,
attempt_action: action.to_s,
user_email: user&.email.presence || "unknown",
user_name: user&.name,
occupying_club_name: occupying_club&.name,
attempted_club_name: attempted_club&.name,
occupying_match_label: occupying.match_label,
attempted_match_label: attempted.match_label,
occupying_status: occupying.status,
occupying_device: occupying_device,
attempted_device: attempted_device,
devices_differ: StreamSession.devices_differ?(occupying, attempted),
metadata: {
occupying_session_id: occupying.id,
attempted_session_id: attempted.id,
occupying_client_os: occupying.client_os,
attempted_client_os: attempted.client_os,
occupying_app_version: occupying.app_version,
attempted_app_version: attempted.app_version
}.compact
)
rescue StandardError => e
Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}")
nil
end
def club_name
attempted_club_name.presence || occupying_club_name
end
def operator_label
[user_name.presence, user_email].compact.join(" · ")
end
end
+33
View File
@@ -10,6 +10,10 @@ class StreamSession < ApplicationRecord
belongs_to :user belongs_to :user
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_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
has_one :score_state, dependent: :destroy has_one :score_state, dependent: :destroy
has_one :recording has_one :recording
has_many :device_states, dependent: :destroy has_many :device_states, dependent: :destroy
@@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord
stream_node&.role.presence || ingest_role stream_node&.role.presence || ingest_role
end end
def match_label
team_name = match&.team&.name
opponent = match&.opponent_name
return id.to_s if team_name.blank?
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
end
def client_device_label
parts = []
parts << client_os if client_os.present?
device = [device_manufacturer, device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{os_version}" if os_version.present?
parts.join(" · ").presence
end
def client_device_key
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
end
def self.devices_differ?(left, right)
ka = left.client_device_key
kb = right.client_device_key
return false if ka.delete("|").blank? || kb.delete("|").blank?
ka != kb
end
def rtmp_ingest_url def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti). # RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path). # MediaMTX path = live/match_{uuid} (no ?token= nel path).
+1
View File
@@ -11,6 +11,7 @@ class User < ApplicationRecord
has_many :clubs, through: :club_memberships has_many :clubs, through: :club_memberships
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
has_many :stream_sessions, dependent: :nullify has_many :stream_sessions, dependent: :nullify
has_many :stream_concurrency_violations, dependent: :nullify
def manageable_teams def manageable_teams
staff_ids = teams.select(:id) staff_ids = teams.select(:id)
@@ -0,0 +1,51 @@
# frozen_string_literal: true
module Sessions
class AssertUserConcurrent
ERROR_CODE = "user_concurrent_stream"
def self.with_lock(session, action: "start")
new(session, action: action).with_lock { yield }
end
def initialize(session, action: "start")
@session = session
@action = action.to_s
end
def with_lock
occupying = nil
User.transaction do
User.lock.find(@session.user_id) if @session.user_id.present?
occupying = occupying_session
yield if occupying.nil?
end
return if occupying.nil?
StreamConcurrencyViolation.record!(
attempted: @session,
occupying: occupying,
action: @action
)
raise Teams::EntitlementError.new(
I18n.t("api.errors.user_concurrent_stream"),
code: ERROR_CODE
)
end
private
def occupying_session
return if @session.user_id.blank?
StreamSession.broadcasting
.where(user_id: @session.user_id)
.where.not(id: @session.id)
.includes(:user, match: { team: :club })
.order(Arel.sql("COALESCE(started_at, updated_at) DESC"))
.first
end
end
end
+4 -2
View File
@@ -10,8 +10,10 @@ module Sessions
end end
cancel_timeout_job cancel_timeout_job
# connecting finché RTMP non è online (evita lose_connection da PublisherSync) Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
@session.begin_connect! if @session.may_begin_connect? # connecting finché RTMP non è online (evita lose_connection da PublisherSync)
@session.begin_connect! if @session.may_begin_connect?
end
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher). # Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
log_event("resumed") log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" }) SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
+5 -3
View File
@@ -5,9 +5,11 @@ module Sessions
end end
def call def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session) Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
@session.begin_connect! if @session.may_begin_connect? @session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.update!(status: "connecting") unless @session.connecting? @session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
end
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube" Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
broadcast_status("connecting") broadcast_status("connecting")
@session @session
@@ -46,3 +46,14 @@
</tbody> </tbody>
</table> </table>
<% end %> <% end %>
<h3 style="font-size:1rem;margin-top:28px"><%= t("admin.clubs.show.concurrency_title") %></h3>
<p class="muted"><%= t("admin.clubs.show.concurrency_lead") %></p>
<%= render "admin/stream_concurrency_violations/table",
violations: @concurrency_violations,
empty_key: "admin.clubs.show.concurrency_none" %>
<% if @concurrency_violations.any? %>
<p class="kpi-sub" style="margin-top:0.75rem">
<%= link_to t("admin.clubs.show.concurrency_all"), admin_stream_concurrency_violations_path(q: @club.name) %>
</p>
<% end %>
@@ -67,6 +67,24 @@
<% end %> <% end %>
</section> </section>
<section class="panel" style="margin-bottom:1.25rem">
<h2><%= t("admin.dashboard.concurrency_panel.title") %></h2>
<% if @concurrency_violation_lookback.positive? %>
<p class="kpi-sub" style="margin-bottom:0.75rem">
<%= t("admin.dashboard.concurrency_panel.count", count: @concurrency_violation_lookback) %>
<%= link_to t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path %>
</p>
<%= render "admin/stream_concurrency_violations/table",
violations: @recent_concurrency_violations,
empty_key: "admin.dashboard.concurrency_panel.none" %>
<% else %>
<p class="empty" style="margin:0">
<%= t("admin.dashboard.concurrency_panel.none_html",
link: link_to(t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path)) %>
</p>
<% end %>
</section>
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));"> <section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
<div class="chart-card"> <div class="chart-card">
<h3><%= t("admin.dashboard.disk.system_title") %></h3> <h3><%= t("admin.dashboard.disk.system_title") %></h3>
@@ -258,6 +258,16 @@
</section> </section>
<% end %> <% end %>
<% if @concurrency_violations.any? %>
<section class="panel">
<h3><%= t("admin.sessions.show.concurrency_title") %></h3>
<p class="muted"><%= t("admin.sessions.show.concurrency_lead") %></p>
<%= render "admin/stream_concurrency_violations/table",
violations: @concurrency_violations,
empty_key: "admin.sessions.show.concurrency_none" %>
</section>
<% end %>
<section class="panel"> <section class="panel">
<h3><%= t("admin.sessions.show.events_title") %></h3> <h3><%= t("admin.sessions.show.events_title") %></h3>
<% if @events.any? %> <% if @events.any? %>
@@ -0,0 +1,67 @@
<% if violations.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.stream_concurrency.table.when") %></th>
<th><%= t("admin.stream_concurrency.table.account") %></th>
<th><%= t("admin.stream_concurrency.table.club") %></th>
<th><%= t("admin.stream_concurrency.table.occupying") %></th>
<th><%= t("admin.stream_concurrency.table.attempted") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% violations.each do |row| %>
<tr class="<%= 'admin-row--two-devices' if row.devices_differ? %>">
<td class="muted"><%= admin_datetime(row.created_at, with_seconds: true) %></td>
<td>
<strong><%= row.user_name.presence || t("admin.common.dash") %></strong>
<div class="muted" style="font-size:0.85rem"><%= row.user_email %></div>
</td>
<td>
<% if row.attempted_club %>
<%= link_to row.club_name, admin_club_path(row.attempted_club) %>
<% elsif row.occupying_club %>
<%= link_to row.club_name, admin_club_path(row.occupying_club) %>
<% else %>
<%= row.club_name.presence || t("admin.common.dash") %>
<% end %>
</td>
<td>
<% if row.occupying_session %>
<%= link_to row.occupying_match_label, admin_session_path(row.occupying_session) %>
<% else %>
<%= row.occupying_match_label %>
<% end %>
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
<span class="badge <%= admin_session_status_badge_class(row.occupying_status) %>"><%= row.occupying_status %></span>
<%= row.occupying_device.presence || t("admin.common.dash") %>
</div>
</td>
<td>
<% if row.attempted_session %>
<%= link_to row.attempted_match_label, admin_session_path(row.attempted_session) %>
<% else %>
<%= row.attempted_match_label %>
<% end %>
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
<%= t("admin.stream_concurrency.action.#{row.attempt_action}") %>
· <%= row.attempted_device.presence || t("admin.common.dash") %>
</div>
</td>
<td>
<% if row.devices_differ? %>
<span class="badge badge--abuse"><%= t("admin.stream_concurrency.badge.two_devices") %></span>
<% else %>
<span class="muted"><%= t("admin.stream_concurrency.badge.same_or_unknown") %></span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t(empty_key) %></p>
<% end %>
@@ -0,0 +1,48 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.stream_concurrency.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.stream_concurrency.index.lead") %></p>
</div>
<section class="kpi-grid">
<div class="kpi-card <%= @lookback_count.positive? ? 'kpi-card--accent' : '' %>">
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.lookback") %></div>
<div class="kpi-value"><%= @lookback_count %></div>
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.lookback_sub") %></div>
</div>
<div class="kpi-card <%= @two_devices_count.positive? ? 'kpi-card--danger' : '' %>">
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.two_devices") %></div>
<div class="kpi-value"><%= @two_devices_count %></div>
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.two_devices_sub") %></div>
</div>
</section>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_stream_concurrency_violations_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.stream_concurrency.filters.q") %></span>
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.stream_concurrency.filters.q_placeholder") %>
</label>
<label class="admin-filter-field admin-filter-field--check">
<span><%= t("admin.stream_concurrency.filters.two_devices") %></span>
<label class="admin-checkbox">
<%= check_box_tag :devices_differ, "1", @filters[:devices_differ] %>
<%= t("admin.stream_concurrency.filters.two_devices_hint") %>
</label>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.stream_concurrency.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.stream_concurrency.filters.reset"), admin_stream_concurrency_violations_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
<span class="muted admin-filter-count">
<%= t("admin.stream_concurrency.index.results", shown: @violations.size, total: @total_count) %>
</span>
</div>
<% end %>
</div>
<div class="panel">
<%= render "admin/stream_concurrency_violations/table", violations: @violations, empty_key: "admin.stream_concurrency.index.none" %>
</div>
+5 -1
View File
@@ -5,7 +5,7 @@
<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">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=15"> <link rel="stylesheet" href="/admin.css?v=16">
<%= yield :head %> <%= yield :head %>
<% 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">
@@ -32,6 +32,10 @@
<%= 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") %>
<% abuse_count = admin_concurrency_violation_lookback_count %>
<%= link_to admin_stream_concurrency_violations_path, class: ("active" if controller_name == "stream_concurrency_violations") do %>
<%= t("admin.layout.nav.stream_concurrency") %><% if abuse_count.positive? %> <span class="admin-nav-badge"><%= abuse_count %></span><% end %>
<% end %>
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %> <%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %> <%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
<%= 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") %>
+1 -1
View File
@@ -10,7 +10,7 @@
<%= render "shared/analytics_suppress" %> <%= render "shared/analytics_suppress" %>
<%= 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=79"> <link rel="stylesheet" href="/marketing.css?v=81">
</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" %>
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %> <%= render "shared/analytics_suppress" %>
<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=79"> <link rel="stylesheet" href="/marketing.css?v=81">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
+2 -2
View File
@@ -164,8 +164,8 @@
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>"> <aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
<p class="demo-label"><%= t("live.index.demo_label") %></p> <p class="demo-label"><%= t("live.index.demo_label") %></p>
<article class="live-card live-card--demo"> <article class="live-card live-card--demo">
<h3>Tigers Volley vs ASD Eagles</h3> <h3><%= MatchLiveTv::Demo.match_title %></h3>
<p class="meta"><%= t("live.index.demo_meta") %></p> <p class="meta"><%= MatchLiveTv::Demo.live_meta %></p>
<p class="card-score"> <p class="card-score">
<span class="card-sets"><%= t("live.index.demo_sets") %></span> <span class="card-sets"><%= t("live.index.demo_sets") %></span>
<span class="card-points">18 - 16</span> <span class="card-points">18 - 16</span>
@@ -80,6 +80,13 @@
) %> ) %>
</p> </p>
</details> </details>
<details class="faq-item">
<summary><%= t("pages.faq.q10_question") %></summary>
<p>
<%= t("pages.faq.q10_answer") %>
</p>
</details>
</div> </div>
<script> <script>
(function () { (function () {
@@ -187,7 +187,7 @@
<div class="features-yt-mock__meta"> <div class="features-yt-mock__meta">
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span> <span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
<div class="features-yt-mock__text"> <div class="features-yt-mock__text">
<strong><%= t("pages.features.youtube_mock_channel") %></strong> <strong><%= MatchLiveTv::Demo.home_team %></strong>
<span><%= t("pages.features.youtube_mock_subs") %></span> <span><%= t("pages.features.youtube_mock_subs") %></span>
</div> </div>
</div> </div>
@@ -196,6 +196,8 @@
</div> </div>
</section> </section>
<%= render "shared/sponsor_cover_promo" %>
<section class="section wrap features-replay" aria-labelledby="features-replay-title"> <section class="section wrap features-replay" aria-labelledby="features-replay-title">
<div class="features-split features-split--reverse"> <div class="features-split features-split--reverse">
<div class="features-split__copy"> <div class="features-split__copy">
@@ -218,7 +220,7 @@
<li> <li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span> <span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
<span class="features-archive-mock__info"> <span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_1_title") %></strong> <strong><%= MatchLiveTv::Demo.match_title %></strong>
<em><%= t("pages.features.replay_mock_1_meta") %></em> <em><%= t("pages.features.replay_mock_1_meta") %></em>
</span> </span>
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -226,7 +228,7 @@
<li> <li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span> <span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
<span class="features-archive-mock__info"> <span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_2_title") %></strong> <strong><%= MatchLiveTv::Demo.match_title %></strong>
<em><%= t("pages.features.replay_mock_2_meta") %></em> <em><%= t("pages.features.replay_mock_2_meta") %></em>
</span> </span>
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -234,7 +236,7 @@
<li> <li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span> <span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
<span class="features-archive-mock__info"> <span class="features-archive-mock__info">
<strong><%= t("pages.features.replay_mock_3_title") %></strong> <strong><%= MatchLiveTv::Demo.match_title %></strong>
<em><%= t("pages.features.replay_mock_3_meta") %></em> <em><%= t("pages.features.replay_mock_3_meta") %></em>
</span> </span>
<i class="fa-solid fa-download" aria-hidden="true"></i> <i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -94,6 +94,8 @@
</div> </div>
</section> </section>
<%= render "shared/sponsor_cover_promo", variant: :compact %>
<section class="section wrap plans-teaser"> <section class="section wrap plans-teaser">
<h2><%= t("home.plans_title") %></h2> <h2><%= t("home.plans_title") %></h2>
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p> <p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
@@ -22,6 +22,8 @@
<%= render "shared/plan_cards" %> <%= render "shared/plan_cards" %>
<%= render "shared/sponsor_cover_promo", variant: :compact, show_cta: false, nested: true %>
<div class="table-scroll compare-table-wrap"> <div class="table-scroll compare-table-wrap">
<table class="compare-table"> <table class="compare-table">
<colgroup> <colgroup>
@@ -50,6 +52,12 @@
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr> <tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr> <tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr> <tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
<tr>
<td><%= t("pages.pricing.table_cover_sponsor") %></td>
<td><%= t("pages.pricing.table_no") %></td>
<td><%= t("pages.pricing.table_no") %></td>
<td><%= t("pages.pricing.table_yes") %></td>
</tr>
<tr> <tr>
<td><%= t("pages.pricing.table_price") %></td> <td><%= t("pages.pricing.table_price") %></td>
<td><%= t("pages.pricing.table_price_free") %></td> <td><%= t("pages.pricing.table_price_free") %></td>
@@ -65,6 +65,9 @@
t("pages.plans.youtube_none") t("pages.plans.youtube_none")
end end
) %></li> ) %></li>
<% if plan.slug == "premium_full" %>
<li><%= raw t("pages.plans.cover_sponsor_html") %></li>
<% end %>
</ul> </ul>
<% if plan.slug == "premium_full" %> <% if plan.slug == "premium_full" %>
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p> <p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
@@ -0,0 +1,26 @@
<figure class="cover-mock" aria-hidden="true" inert>
<div class="cover-mock__chrome">
<span class="cover-mock__brand">MATCH <em>LIVE</em> TV</span>
<span class="cover-mock__state"><%= t("pages.sponsor_cover.mock.state") %></span>
</div>
<div class="cover-mock__stage">
<p class="cover-mock__cat"><%= MatchLiveTv::Demo.category %></p>
<p class="cover-mock__home"><%= MatchLiveTv::Demo.home_team %></p>
<p class="cover-mock__vs">vs</p>
<p class="cover-mock__away"><%= MatchLiveTv::Demo.away_team %></p>
<p class="cover-mock__when"><%= MatchLiveTv::Demo.when_label %></p>
<p class="cover-mock__art-label"><%= t("pages.sponsor_cover.mock.art_label") %></p>
<div class="cover-mock__art">
<div class="cover-mock__crest">
<span class="cover-mock__crest-mark">M</span>
<span class="cover-mock__crest-name"><%= MatchLiveTv::Demo.home_team %></span>
</div>
<div class="cover-mock__marks">
<span class="cover-mock__mark cover-mock__mark--a"></span>
<span class="cover-mock__mark cover-mock__mark--b"></span>
<span class="cover-mock__mark cover-mock__mark--c"></span>
</div>
</div>
<p class="cover-mock__soon"><%= t("pages.sponsor_cover.mock.soon") %></p>
</div>
</figure>
@@ -0,0 +1,30 @@
<%# locals: (variant: :full, show_cta: nil, nested: false) %>
<% variant = (local_assigns[:variant] || :full).to_sym %>
<% compact = variant == :compact %>
<% nested = local_assigns[:nested] %>
<% show_cta = local_assigns.fetch(:show_cta, true) %>
<% title_id = compact ? "sponsor-cover-title-compact" : "sponsor-cover-title" %>
<section class="section<%= " wrap" unless nested %> sponsor-cover<%= " sponsor-cover--compact" if compact %>" aria-labelledby="<%= title_id %>">
<div class="sponsor-cover__panel">
<div class="sponsor-cover__copy">
<p class="feature-card__badge feature-card__badge--gold"><%= t("pages.sponsor_cover.eyebrow") %></p>
<h2 id="<%= title_id %>"><%= t("pages.sponsor_cover.title") %></h2>
<p class="sponsor-cover__lead"><%= t("pages.sponsor_cover.body") %></p>
<% unless compact %>
<ul class="features-checklist">
<li><%= t("pages.sponsor_cover.item_cover") %></li>
<li><%= t("pages.sponsor_cover.item_before") %></li>
<li><%= t("pages.sponsor_cover.item_club") %></li>
</ul>
<p class="sponsor-cover__claim"><%= t("pages.sponsor_cover.claim") %></p>
<% end %>
<% if show_cta %>
<%= link_to t("pages.sponsor_cover.cta"), public_prezzi_path, class: "btn btn-outline" %>
<% end %>
</div>
<div class="sponsor-cover__visual">
<%= render "shared/sponsor_cover_mock" %>
</div>
</div>
</section>
+43
View File
@@ -10,6 +10,7 @@ de:
billing: Abrechnung billing: Abrechnung
youtube: YouTube youtube: YouTube
sessions: Sitzungen sessions: Sitzungen
stream_concurrency: Konto-Missbrauch
analytics: Analytics analytics: Analytics
costs: Kosten costs: Kosten
stream_nodes: Stream-Knoten stream_nodes: Stream-Knoten
@@ -103,6 +104,12 @@ de:
warnings: "%{count} Warnung(en)" warnings: "%{count} Warnung(en)"
dashboard_link: Ops-Dashboard dashboard_link: Ops-Dashboard
none_html: "Keine offenen Vorfälle. %{link}" none_html: "Keine offenen Vorfälle. %{link}"
concurrency_panel:
title: Konto-Missbrauch (zweite Direktübertragung)
count: "%{count} Versuch(e) in den letzten 30 Tagen"
view_all: Alle ansehen
none: Keine Versuche erfasst.
none_html: "Keine Versuche einer zweiten Direktübertragung mit demselben Konto. %{link}"
disk: disk:
system_title: Systemfestplatte system_title: Systemfestplatte
free_label: "Frei: %{free} (%{percent}% belegt)" free_label: "Frei: %{free} (%{percent}% belegt)"
@@ -193,6 +200,36 @@ de:
table: table:
resolved_at: Gelöst resolved_at: Gelöst
none: Kürzlich keine Vorfälle gelöst. none: Kürzlich keine Vorfälle gelöst.
stream_concurrency:
index:
title: Konto-Missbrauch
lead: Versuche, mit demselben Konto eine zweite Direktübertragung zu starten, während bereits eine lief (connecting, live, reconnecting oder pausiert).
results: "%{shown} von %{total} angezeigt"
none: Keine Versuche erfasst.
kpi:
lookback: Letzte 30 Tage
lookback_sub: blockierte Versuche
two_devices: Zwei Geräte
two_devices_sub: unterschiedliche Handy-Modelle
filters:
q: Suche
q_placeholder: E-Mail, Verein, Spiel…
two_devices: Filter
two_devices_hint: Nur Versuche von unterschiedlichen Geräten
apply: Filtern
reset: Zurücksetzen
table:
when: Wann
account: Konto
club: Verein
occupying: Bereits laufende Direktübertragung
attempted: Blockierter Versuch
action:
start: Start
resume: Fortsetzen
badge:
two_devices: 2 Telefone
same_or_unknown: gleiches Gerät / n. v.
clubs: clubs:
index: index:
title: Vereine & Teams title: Vereine & Teams
@@ -224,6 +261,10 @@ de:
sport: Sportart sport: Sportart
matches_and_details: Spiele & Details matches_and_details: Spiele & Details
replay: Replays replay: Replays
concurrency_title: Versuche einer zweiten Direktübertragung
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief.
concurrency_none: Keine Versuche für diesen Verein erfasst.
concurrency_all: Alle Konto-Missbräuche ansehen
comped: comped:
title: Kostenloses Abonnement title: Kostenloses Abonnement
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar." description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
@@ -296,6 +337,8 @@ de:
devices_title: Geräte devices_title: Geräte
events_title: Ereignisse events_title: Ereignisse
events_none: Keine Ereignisse erfasst. events_none: Keine Ereignisse erfasst.
concurrency_title: Versuche einer zweiten Direktübertragung
concurrency_lead: Dieses Konto hat versucht, eine weitere Direktübertragung zu starten, während diese bereits lief (oder wurde von einer anderen Sitzung desselben Kontos blockiert).
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."
+43
View File
@@ -10,6 +10,7 @@ en:
billing: Billing billing: Billing
youtube: YouTube youtube: YouTube
sessions: Sessions sessions: Sessions
stream_concurrency: Account abuse
analytics: Analytics analytics: Analytics
costs: Costs costs: Costs
stream_nodes: Stream nodes stream_nodes: Stream nodes
@@ -103,6 +104,12 @@ en:
warnings: "%{count} warning(s)" warnings: "%{count} warning(s)"
dashboard_link: Ops dashboard dashboard_link: Ops dashboard
none_html: "No open incidents. %{link}" none_html: "No open incidents. %{link}"
concurrency_panel:
title: Account abuse (second live)
count: "%{count} attempt(s) in the last 30 days"
view_all: View all
none: No attempts recorded.
none_html: "No second-live attempts from the same account. %{link}"
disk: disk:
system_title: System disk system_title: System disk
free_label: "Free: %{free} (%{percent}% used)" free_label: "Free: %{free} (%{percent}% used)"
@@ -193,6 +200,36 @@ en:
table: table:
resolved_at: Resolved resolved_at: Resolved
none: No incidents resolved recently. none: No incidents resolved recently.
stream_concurrency:
index:
title: Account abuse
lead: Attempts to start a second live with the same account while another was already running (connecting, live, reconnecting or paused).
results: "Showing %{shown} of %{total}"
none: No attempts recorded.
kpi:
lookback: Last 30 days
lookback_sub: blocked attempts
two_devices: Two devices
two_devices_sub: different phone models
filters:
q: Search
q_placeholder: Email, club, match…
two_devices: Filter
two_devices_hint: Only attempts from different devices
apply: Filter
reset: Reset
table:
when: When
account: Account
club: Club
occupying: Live already running
attempted: Blocked attempt
action:
start: start
resume: resume
badge:
two_devices: 2 phones
same_or_unknown: same device / n.a.
clubs: clubs:
index: index:
title: Clubs & teams title: Clubs & teams
@@ -224,6 +261,10 @@ en:
sport: Sport sport: Sport
matches_and_details: Matches & details matches_and_details: Matches & details
replay: Replays replay: Replays
concurrency_title: Second-live attempts
concurrency_lead: Same account tried to start another live while one was already running.
concurrency_none: No attempts recorded for this club.
concurrency_all: View all account abuse
comped: comped:
title: Complimentary subscription title: Complimentary subscription
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time." description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
@@ -296,6 +337,8 @@ en:
devices_title: Devices devices_title: Devices
events_title: Events events_title: Events
events_none: No events recorded. events_none: No events recorded.
concurrency_title: Second-live attempts
concurrency_lead: This account tried to start another live while this one was already running (or was blocked by another session of the same account).
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."
+43
View File
@@ -10,6 +10,7 @@ es:
billing: Facturación billing: Facturación
youtube: YouTube youtube: YouTube
sessions: Sesiones sessions: Sesiones
stream_concurrency: Abuso de cuenta
analytics: Analytics analytics: Analytics
costs: Costes costs: Costes
stream_nodes: Nodos stream stream_nodes: Nodos stream
@@ -103,6 +104,12 @@ es:
warnings: "%{count} aviso(s)" warnings: "%{count} aviso(s)"
dashboard_link: Panel de Ops dashboard_link: Panel de Ops
none_html: "No hay incidencias abiertas. %{link}" none_html: "No hay incidencias abiertas. %{link}"
concurrency_panel:
title: Abuso de cuenta (segundo directo)
count: "%{count} intento(s) en los últimos 30 días"
view_all: Ver todos
none: No hay intentos registrados.
none_html: "Ningún intento de segundo directo con la misma cuenta. %{link}"
disk: disk:
system_title: Disco del sistema system_title: Disco del sistema
free_label: "Libre: %{free} (%{percent}% usado)" free_label: "Libre: %{free} (%{percent}% usado)"
@@ -193,6 +200,36 @@ es:
table: table:
resolved_at: Resuelta resolved_at: Resuelta
none: No se han resuelto incidencias recientemente. none: No se han resuelto incidencias recientemente.
stream_concurrency:
index:
title: Abuso de cuenta
lead: Intentos de iniciar un segundo directo con la misma cuenta mientras ya había otro en curso (connecting, live, reconnecting o en pausa).
results: "Mostrando %{shown} de %{total}"
none: No hay intentos registrados.
kpi:
lookback: Últimos 30 días
lookback_sub: intentos bloqueados
two_devices: Dos dispositivos
two_devices_sub: modelos de teléfono distintos
filters:
q: Buscar
q_placeholder: Email, club, partido…
two_devices: Filtro
two_devices_hint: Solo intentos desde dispositivos distintos
apply: Filtrar
reset: Restablecer
table:
when: Cuándo
account: Cuenta
club: Club
occupying: Directo ya en curso
attempted: Intento bloqueado
action:
start: inicio
resume: reanudación
badge:
two_devices: 2 teléfonos
same_or_unknown: mismo dispositivo / n. d.
clubs: clubs:
index: index:
title: Clubes y equipos title: Clubes y equipos
@@ -224,6 +261,10 @@ es:
sport: Deporte sport: Deporte
matches_and_details: Partidos y detalles matches_and_details: Partidos y detalles
replay: Repeticiones replay: Repeticiones
concurrency_title: Intentos de segundo directo
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso.
concurrency_none: No hay intentos registrados para este club.
concurrency_all: Ver todos los abusos de cuenta
comped: comped:
title: Suscripción de cortesía title: Suscripción de cortesía
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento." description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
@@ -296,6 +337,8 @@ es:
devices_title: Dispositivos devices_title: Dispositivos
events_title: Eventos events_title: Eventos
events_none: No hay eventos registrados. events_none: No hay eventos registrados.
concurrency_title: Intentos de segundo directo
concurrency_lead: Esta cuenta intentó iniciar otro directo mientras este ya estaba en curso (o fue bloqueada por otra sesión de la misma cuenta).
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."
+43
View File
@@ -10,6 +10,7 @@ fr:
billing: Facturation billing: Facturation
youtube: YouTube youtube: YouTube
sessions: Sessions sessions: Sessions
stream_concurrency: Abus de compte
analytics: Analytics analytics: Analytics
costs: Coûts costs: Coûts
stream_nodes: Nœuds stream stream_nodes: Nœuds stream
@@ -103,6 +104,12 @@ fr:
warnings: "%{count} avertissement(s)" warnings: "%{count} avertissement(s)"
dashboard_link: Tableau de bord Ops dashboard_link: Tableau de bord Ops
none_html: "Aucun incident ouvert. %{link}" none_html: "Aucun incident ouvert. %{link}"
concurrency_panel:
title: Abus de compte (deuxième direct)
count: "%{count} tentative(s) sur les 30 derniers jours"
view_all: Voir tout
none: Aucune tentative enregistrée.
none_html: "Aucune tentative de second direct avec le même compte. %{link}"
disk: disk:
system_title: Disque système system_title: Disque système
free_label: "Libre : %{free} (%{percent}% utilisé)" free_label: "Libre : %{free} (%{percent}% utilisé)"
@@ -193,6 +200,36 @@ fr:
table: table:
resolved_at: Résolu resolved_at: Résolu
none: Aucun incident résolu récemment. none: Aucun incident résolu récemment.
stream_concurrency:
index:
title: Abus de compte
lead: Tentatives de démarrer un second direct avec le même compte alors quun autre était déjà en cours (connecting, live, reconnecting ou en pause).
results: "%{shown} sur %{total} affichés"
none: Aucune tentative enregistrée.
kpi:
lookback: 30 derniers jours
lookback_sub: tentatives bloquées
two_devices: Deux appareils
two_devices_sub: modèles de téléphone différents
filters:
q: Rechercher
q_placeholder: E-mail, club, match…
two_devices: Filtre
two_devices_hint: Uniquement les tentatives depuis des appareils différents
apply: Filtrer
reset: Réinitialiser
table:
when: Quand
account: Compte
club: Club
occupying: Direct déjà en cours
attempted: Tentative bloquée
action:
start: démarrage
resume: reprise
badge:
two_devices: 2 téléphones
same_or_unknown: même appareil / n. d.
clubs: clubs:
index: index:
title: Clubs et équipes title: Clubs et équipes
@@ -224,6 +261,10 @@ fr:
sport: Sport sport: Sport
matches_and_details: Matchs et détails matches_and_details: Matchs et détails
replay: Replays replay: Replays
concurrency_title: Tentatives de second direct
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours.
concurrency_none: Aucune tentative enregistrée pour ce club.
concurrency_all: Voir tous les abus de compte
comped: comped:
title: Abonnement offert title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment." description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
@@ -296,6 +337,8 @@ fr:
devices_title: Appareils devices_title: Appareils
events_title: Événements events_title: Événements
events_none: Aucun événement enregistré. events_none: Aucun événement enregistré.
concurrency_title: Tentatives de second direct
concurrency_lead: Ce compte a tenté de démarrer un autre direct alors que celui-ci était déjà en cours (ou a été bloqué par une autre session du même compte).
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é."
+43
View File
@@ -10,6 +10,7 @@ it:
billing: Fatturazione billing: Fatturazione
youtube: YouTube youtube: YouTube
sessions: Sessioni sessions: Sessioni
stream_concurrency: Abusi account
analytics: Analytics analytics: Analytics
costs: Costi costs: Costi
stream_nodes: Nodi stream stream_nodes: Nodi stream
@@ -107,6 +108,12 @@ it:
warnings: "%{count} warning" warnings: "%{count} warning"
dashboard_link: Dashboard Ops dashboard_link: Dashboard Ops
none_html: "Nessun incidente aperto. %{link}" none_html: "Nessun incidente aperto. %{link}"
concurrency_panel:
title: Abusi account (seconda diretta)
count: "%{count} tentativo/i negli ultimi 30 giorni"
view_all: Vedi tutti
none: Nessun tentativo registrato.
none_html: "Nessun tentativo di seconda diretta dallo stesso account. %{link}"
disk: disk:
system_title: Disco sistema system_title: Disco sistema
free_label: "Libero: %{free} (%{percent}% usato)" free_label: "Libero: %{free} (%{percent}% usato)"
@@ -197,6 +204,36 @@ it:
table: table:
resolved_at: Risolto resolved_at: Risolto
none: Nessun incidente risolto di recente. none: Nessun incidente risolto di recente.
stream_concurrency:
index:
title: Abusi account
lead: Tentativi di avviare una seconda diretta con lo stesso account mentre unaltra era già in corso (connecting, live, reconnecting o in pausa).
results: "Mostrate %{shown} di %{total}"
none: Nessun tentativo registrato.
kpi:
lookback: Ultimi 30 giorni
lookback_sub: tentativi bloccati
two_devices: Due dispositivi
two_devices_sub: modelli telefono diversi
filters:
q: Cerca
q_placeholder: Email, società, partita…
two_devices: Filtro
two_devices_hint: Solo tentativi da dispositivi diversi
apply: Filtra
reset: Reset
table:
when: Quando
account: Account
club: Società
occupying: Diretta già in corso
attempted: Tentativo bloccato
action:
start: avvio
resume: ripresa
badge:
two_devices: 2 telefoni
same_or_unknown: stesso device / n.d.
clubs: clubs:
index: index:
title: Società e squadre title: Società e squadre
@@ -229,6 +266,10 @@ it:
sport: Sport sport: Sport
matches_and_details: Partite e dettagli matches_and_details: Partite e dettagli
replay: Replay replay: Replay
concurrency_title: Tentativi di seconda diretta
concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una.
concurrency_none: Nessun tentativo registrato per questa società.
concurrency_all: Vedi tutti gli abusi account
comped: comped:
title: Abbonamento omaggio title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento." description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
@@ -317,6 +358,8 @@ it:
devices_title: Dispositivi devices_title: Dispositivi
events_title: Eventi events_title: Eventi
events_none: Nessun evento registrato. events_none: Nessun evento registrato.
concurrency_title: Tentativi di seconda diretta
concurrency_lead: Questo account ha provato ad avviare unaltra diretta mentre questa era già in corso (o è stato bloccato da unaltra sessione dello stesso account).
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."
+11 -8
View File
@@ -1,4 +1,7 @@
de: de:
api:
errors:
user_concurrent_stream: "Mit diesem Konto läuft bereits eine Direktübertragung. Beende sie, bevor du eine weitere startest."
password_policy: password_policy:
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen." hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
activerecord: activerecord:
@@ -77,10 +80,10 @@ de:
title: "Verein registrieren — Match Live TV" title: "Verein registrieren — Match Live TV"
meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV. meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV.
heading: Dein Verein heading: Dein Verein
lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, Serie C…)." lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, erste Mannschaft…)."
section_club: Verein section_club: Verein
name_label: Vereinsname / Club name_label: Vereinsname / Club
name_placeholder: "z. B. Crazy Volley Rozzano" name_placeholder: "z. B. Team MLTV"
section_first_team: Erstes Team section_first_team: Erstes Team
default_first_team_name: Erstes Team default_first_team_name: Erstes Team
first_team_name_label: Teamname first_team_name_label: Teamname
@@ -159,7 +162,7 @@ de:
heading: Neues Team heading: Neues Team
club_label: "Verein:" club_label: "Verein:"
name_label: Teamname name_label: Teamname
name_placeholder: "z. B. Under 13, Serie C" name_placeholder: "z. B. U15 männlich"
branding_legend: Branding-Überschreibung (optional) branding_legend: Branding-Überschreibung (optional)
submit: Team hinzufügen submit: Team hinzufügen
invite: invite:
@@ -235,7 +238,7 @@ de:
matches: matches:
back_to_list: "← Spieleliste" back_to_list: "← Spieleliste"
opponent_label: Gegner opponent_label: Gegner
opponent_placeholder: "z. B. Volley Milano" opponent_placeholder: "z. B. Team Guest"
location_label: "Ort (optional)" location_label: "Ort (optional)"
location_placeholder: "Halle, Stadt" location_placeholder: "Halle, Stadt"
datetime_label: Datum und Uhrzeit datetime_label: Datum und Uhrzeit
@@ -361,7 +364,7 @@ de:
legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt." legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt."
entity_type_label: "Art des Rechnungsempfängers *" entity_type_label: "Art des Rechnungsempfängers *"
legal_name_label: "Firmenname / Vor- und Nachname *" legal_name_label: "Firmenname / Vor- und Nachname *"
legal_name_placeholder: "z. B. ASD Tigers Volley" legal_name_placeholder: "z. B. Team MLTV"
vat_number_label: "USt-IdNr. * (Verein)" vat_number_label: "USt-IdNr. * (Verein)"
fiscal_code_label: "Steuernummer * (Privatperson)" fiscal_code_label: "Steuernummer * (Privatperson)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ de:
replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv" replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv"
schedule_match_link: "Spiel planen" schedule_match_link: "Spiel planen"
search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…" search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…"
search_placeholder_default: "Z. B. Crazy Volley, Serie D, Gegner…" search_placeholder_default: "Z. B. Team MLTV, Team Guest…"
search_aria_label: "Team suchen" search_aria_label: "Team suchen"
search_button: "Suchen" search_button: "Suchen"
reset_link: "Zurücksetzen" reset_link: "Zurücksetzen"
@@ -772,7 +775,7 @@ de:
empty_hero_cta_features: "Erfahre, wie es funktioniert" empty_hero_cta_features: "Erfahre, wie es funktioniert"
demo_aria_label: "Beispiel einer aktiven Live-Übertragung" demo_aria_label: "Beispiel einer aktiven Live-Übertragung"
demo_label: "Beispiel — so sieht eine aktive Übertragung aus" demo_label: "Beispiel — so sieht eine aktive Übertragung aus"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Satz 2 · Sätze gewonnen 1-0" demo_sets: "Satz 2 · Sätze gewonnen 1-0"
show: show:
back_to_all: "← Alle Live-Übertragungen" back_to_all: "← Alle Live-Übertragungen"
@@ -805,7 +808,7 @@ de:
back_to_live: "← Live-Übertragungen" back_to_live: "← Live-Übertragungen"
title: "Vergangene Live-Übertragungen" title: "Vergangene Live-Übertragungen"
hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele." hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele."
search_placeholder: "Z. B. Tigers Volley, Gegner, Verein, Ort…" search_placeholder: "Z. B. Team MLTV, Team Guest…"
search_aria_label: "Wiederholung suchen" search_aria_label: "Wiederholung suchen"
search_button: "Suchen" search_button: "Suchen"
reset_link: "Zurücksetzen" reset_link: "Zurücksetzen"
+11 -8
View File
@@ -1,4 +1,7 @@
en: en:
api:
errors:
user_concurrent_stream: "You already have a live stream running on this account. Stop it before starting another."
password_policy: password_policy:
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols." hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
activerecord: activerecord:
@@ -82,10 +85,10 @@ en:
title: "Register a club — Match Live TV" title: "Register a club — Match Live TV"
meta_description: Create your sports club and its first team on Match Live TV. meta_description: Create your sports club and its first team on Match Live TV.
heading: Your club heading: Your club
lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, Serie C…)." lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, first team…)."
section_club: Club section_club: Club
name_label: Club name name_label: Club name
name_placeholder: "e.g. Crazy Volley Rozzano" name_placeholder: "e.g. Team MLTV"
section_first_team: First team section_first_team: First team
default_first_team_name: First team default_first_team_name: First team
first_team_name_label: Team name first_team_name_label: Team name
@@ -164,7 +167,7 @@ en:
heading: New team heading: New team
club_label: "Club:" club_label: "Club:"
name_label: Team name name_label: Team name
name_placeholder: "e.g. Under 13, Serie C" name_placeholder: "e.g. U15 boys"
branding_legend: Branding override (optional) branding_legend: Branding override (optional)
submit: Add team submit: Add team
invite: invite:
@@ -240,7 +243,7 @@ en:
matches: matches:
back_to_list: "← Match list" back_to_list: "← Match list"
opponent_label: Opponent opponent_label: Opponent
opponent_placeholder: "e.g. Volley Milano" opponent_placeholder: "e.g. Team Guest"
location_label: "Location (optional)" location_label: "Location (optional)"
location_placeholder: "Gym, city" location_placeholder: "Gym, city"
datetime_label: Date and time datetime_label: Date and time
@@ -366,7 +369,7 @@ en:
legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe." legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe."
entity_type_label: "Billing entity type *" entity_type_label: "Billing entity type *"
legal_name_label: "Legal name / full name *" legal_name_label: "Legal name / full name *"
legal_name_placeholder: "e.g. ASD Tigers Volley" legal_name_placeholder: "e.g. Team MLTV"
vat_number_label: "VAT number * (club)" vat_number_label: "VAT number * (club)"
fiscal_code_label: "Fiscal code * (private individual)" fiscal_code_label: "Fiscal code * (private individual)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -743,7 +746,7 @@ en:
replay_archive_link: "Past live streams — replay archive" replay_archive_link: "Past live streams — replay archive"
schedule_match_link: "Schedule a match" schedule_match_link: "Schedule a match"
search_placeholder_club: "Search club, team, opponent or venue…" search_placeholder_club: "Search club, team, opponent or venue…"
search_placeholder_default: "E.g. Crazy Volley, Serie D, opponent…" search_placeholder_default: "E.g. Team MLTV, Team Guest…"
search_aria_label: "Search team" search_aria_label: "Search team"
search_button: "Search" search_button: "Search"
reset_link: "Reset" reset_link: "Reset"
@@ -777,7 +780,7 @@ en:
empty_hero_cta_features: "See how it works" empty_hero_cta_features: "See how it works"
demo_aria_label: "Example of an active live stream" demo_aria_label: "Example of an active live stream"
demo_label: "Example — this is how an active stream looks" demo_label: "Example — this is how an active stream looks"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets won 1-0" demo_sets: "Set 2 · Sets won 1-0"
show: show:
back_to_all: "← All live streams" back_to_all: "← All live streams"
@@ -810,7 +813,7 @@ en:
back_to_live: "← Live streams" back_to_live: "← Live streams"
title: "Past live streams" title: "Past live streams"
hint: "Public replays from sports clubs — covers matches already broadcast." hint: "Public replays from sports clubs — covers matches already broadcast."
search_placeholder: "E.g. Tigers Volley, opponent, club, venue…" search_placeholder: "E.g. Team MLTV, Team Guest…"
search_aria_label: "Search replays" search_aria_label: "Search replays"
search_button: "Search" search_button: "Search"
reset_link: "Reset" reset_link: "Reset"
+11 -8
View File
@@ -1,4 +1,7 @@
es: es:
api:
errors:
user_concurrent_stream: "Ya tienes un directo en curso con esta cuenta. Ciérralo antes de iniciar otro."
password_policy: password_policy:
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos." hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
activerecord: activerecord:
@@ -77,10 +80,10 @@ es:
title: "Registrar un club — Match Live TV" title: "Registrar un club — Match Live TV"
meta_description: Crea tu club deportivo y su primer equipo en Match Live TV. meta_description: Crea tu club deportivo y su primer equipo en Match Live TV.
heading: Tu club heading: Tu club
lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, Serie C…)." lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, primer equipo…)."
section_club: Club section_club: Club
name_label: Nombre del club name_label: Nombre del club
name_placeholder: "ej. Crazy Volley Rozzano" name_placeholder: "ej. Team MLTV"
section_first_team: Primer equipo section_first_team: Primer equipo
default_first_team_name: Primer equipo default_first_team_name: Primer equipo
first_team_name_label: Nombre del equipo first_team_name_label: Nombre del equipo
@@ -159,7 +162,7 @@ es:
heading: Nuevo equipo heading: Nuevo equipo
club_label: "Club:" club_label: "Club:"
name_label: Nombre del equipo name_label: Nombre del equipo
name_placeholder: "ej. Sub-13, Serie C" name_placeholder: "ej. U15 masculino"
branding_legend: Personalización de imagen de marca (opcional) branding_legend: Personalización de imagen de marca (opcional)
submit: Añadir equipo submit: Añadir equipo
invite: invite:
@@ -235,7 +238,7 @@ es:
matches: matches:
back_to_list: "← Lista de partidos" back_to_list: "← Lista de partidos"
opponent_label: Rival opponent_label: Rival
opponent_placeholder: "ej. Volley Milano" opponent_placeholder: "ej. Team Guest"
location_label: "Lugar (opcional)" location_label: "Lugar (opcional)"
location_placeholder: "Pabellón, ciudad" location_placeholder: "Pabellón, ciudad"
datetime_label: Fecha y hora datetime_label: Fecha y hora
@@ -361,7 +364,7 @@ es:
legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe." legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe."
entity_type_label: "Tipo de titular *" entity_type_label: "Tipo de titular *"
legal_name_label: "Razón social / nombre y apellidos *" legal_name_label: "Razón social / nombre y apellidos *"
legal_name_placeholder: "ej. ASD Tigers Volley" legal_name_placeholder: "ej. Team MLTV"
vat_number_label: "NIF/CIF * (club)" vat_number_label: "NIF/CIF * (club)"
fiscal_code_label: "Código fiscal * (persona física)" fiscal_code_label: "Código fiscal * (persona física)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ es:
replay_archive_link: "Directos pasados — archivo de repeticiones" replay_archive_link: "Directos pasados — archivo de repeticiones"
schedule_match_link: "Programar partido" schedule_match_link: "Programar partido"
search_placeholder_club: "Buscar club, equipo, rival o lugar…" search_placeholder_club: "Buscar club, equipo, rival o lugar…"
search_placeholder_default: "Ej. Crazy Volley, Serie D, rival…" search_placeholder_default: "Ej. Team MLTV, Team Guest…"
search_aria_label: "Buscar equipo" search_aria_label: "Buscar equipo"
search_button: "Buscar" search_button: "Buscar"
reset_link: "Restablecer" reset_link: "Restablecer"
@@ -772,7 +775,7 @@ es:
empty_hero_cta_features: "Descubre cómo funciona" empty_hero_cta_features: "Descubre cómo funciona"
demo_aria_label: "Ejemplo de un directo activo" demo_aria_label: "Ejemplo de un directo activo"
demo_label: "Ejemplo — así se ve un directo activo" demo_label: "Ejemplo — así se ve un directo activo"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets ganados 1-0" demo_sets: "Set 2 · Sets ganados 1-0"
show: show:
back_to_all: "← Todos los directos" back_to_all: "← Todos los directos"
@@ -805,7 +808,7 @@ es:
back_to_live: "← Directos en curso" back_to_live: "← Directos en curso"
title: "Directos pasados" title: "Directos pasados"
hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos." hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos."
search_placeholder: "Ej. Tigers Volley, rival, club, lugar…" search_placeholder: "Ej. Team MLTV, Team Guest…"
search_aria_label: "Buscar repetición" search_aria_label: "Buscar repetición"
search_button: "Buscar" search_button: "Buscar"
reset_link: "Restablecer" reset_link: "Restablecer"
+11 -8
View File
@@ -1,4 +1,7 @@
fr: fr:
api:
errors:
user_concurrent_stream: "Un direct est déjà en cours avec ce compte. Arrêtez-le avant den démarrer un autre."
password_policy: password_policy:
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles." hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
activerecord: activerecord:
@@ -77,10 +80,10 @@ fr:
title: "Inscrire un club — Match Live TV" title: "Inscrire un club — Match Live TV"
meta_description: Crée ton club sportif et sa première équipe sur Match Live TV. meta_description: Crée ton club sportif et sa première équipe sur Match Live TV.
heading: Ton club heading: Ton club
lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, Serie C…)." lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, première équipe…)."
section_club: Club section_club: Club
name_label: Nom du club name_label: Nom du club
name_placeholder: "ex. Crazy Volley Rozzano" name_placeholder: "ex. Team MLTV"
section_first_team: Première équipe section_first_team: Première équipe
default_first_team_name: Première équipe default_first_team_name: Première équipe
first_team_name_label: Nom de l'équipe first_team_name_label: Nom de l'équipe
@@ -159,7 +162,7 @@ fr:
heading: Nouvelle équipe heading: Nouvelle équipe
club_label: "Club :" club_label: "Club :"
name_label: Nom de l'équipe name_label: Nom de l'équipe
name_placeholder: "ex. Under 13, Serie C" name_placeholder: "ex. U15 masculin"
branding_legend: Personnalisation de l'image de marque (facultatif) branding_legend: Personnalisation de l'image de marque (facultatif)
submit: Ajouter l'équipe submit: Ajouter l'équipe
invite: invite:
@@ -235,7 +238,7 @@ fr:
matches: matches:
back_to_list: "← Liste des matchs" back_to_list: "← Liste des matchs"
opponent_label: Adversaire opponent_label: Adversaire
opponent_placeholder: "ex. Volley Milano" opponent_placeholder: "ex. Team Guest"
location_label: "Lieu (facultatif)" location_label: "Lieu (facultatif)"
location_placeholder: "Gymnase, ville" location_placeholder: "Gymnase, ville"
datetime_label: Date et heure datetime_label: Date et heure
@@ -361,7 +364,7 @@ fr:
legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe." legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe."
entity_type_label: "Type de titulaire *" entity_type_label: "Type de titulaire *"
legal_name_label: "Raison sociale / nom et prénom *" legal_name_label: "Raison sociale / nom et prénom *"
legal_name_placeholder: "ex. ASD Tigers Volley" legal_name_placeholder: "ex. Team MLTV"
vat_number_label: "Numéro de TVA * (club)" vat_number_label: "Numéro de TVA * (club)"
fiscal_code_label: "Code fiscal * (personne physique)" fiscal_code_label: "Code fiscal * (personne physique)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ fr:
replay_archive_link: "Directs passés — archive des replays" replay_archive_link: "Directs passés — archive des replays"
schedule_match_link: "Programmer un match" schedule_match_link: "Programmer un match"
search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…" search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…"
search_placeholder_default: "Ex. Crazy Volley, Série D, adversaire…" search_placeholder_default: "Ex. Team MLTV, Team Guest…"
search_aria_label: "Rechercher une équipe" search_aria_label: "Rechercher une équipe"
search_button: "Rechercher" search_button: "Rechercher"
reset_link: "Réinitialiser" reset_link: "Réinitialiser"
@@ -772,7 +775,7 @@ fr:
empty_hero_cta_features: "Découvrez comment ça marche" empty_hero_cta_features: "Découvrez comment ça marche"
demo_aria_label: "Exemple de direct actif" demo_aria_label: "Exemple de direct actif"
demo_label: "Exemple — voici à quoi ressemble un direct actif" demo_label: "Exemple — voici à quoi ressemble un direct actif"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets gagnés 1-0" demo_sets: "Set 2 · Sets gagnés 1-0"
show: show:
back_to_all: "← Tous les directs" back_to_all: "← Tous les directs"
@@ -805,7 +808,7 @@ fr:
back_to_live: "← Directs en cours" back_to_live: "← Directs en cours"
title: "Directs passés" title: "Directs passés"
hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés." hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés."
search_placeholder: "Ex. Tigers Volley, adversaire, club, lieu…" search_placeholder: "Ex. Team MLTV, Team Guest…"
search_aria_label: "Rechercher un replay" search_aria_label: "Rechercher un replay"
search_button: "Rechercher" search_button: "Rechercher"
reset_link: "Réinitialiser" reset_link: "Réinitialiser"
+11 -8
View File
@@ -1,4 +1,7 @@
it: it:
api:
errors:
user_concurrent_stream: "Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra."
password_policy: password_policy:
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli." hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
activerecord: activerecord:
@@ -82,10 +85,10 @@ it:
title: "Registra società — Match Live TV" title: "Registra società — Match Live TV"
meta_description: Crea la società sportiva e la prima squadra su Match Live TV. meta_description: Crea la società sportiva e la prima squadra su Match Live TV.
heading: La tua società heading: La tua società
lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, Serie C…)." lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, prima squadra…)."
section_club: Società section_club: Società
name_label: Nome società / club name_label: Nome società / club
name_placeholder: "es. Crazy Volley Rozzano" name_placeholder: "es. Team MLTV"
section_first_team: Prima squadra section_first_team: Prima squadra
default_first_team_name: Prima squadra default_first_team_name: Prima squadra
first_team_name_label: Nome squadra first_team_name_label: Nome squadra
@@ -164,7 +167,7 @@ it:
heading: Nuova squadra heading: Nuova squadra
club_label: "Società:" club_label: "Società:"
name_label: Nome squadra name_label: Nome squadra
name_placeholder: "es. Under 13, Serie C" name_placeholder: "es. U15 maschile"
branding_legend: Override branding (opzionale) branding_legend: Override branding (opzionale)
submit: Aggiungi squadra submit: Aggiungi squadra
invite: invite:
@@ -240,7 +243,7 @@ it:
matches: matches:
back_to_list: "← Elenco partite" back_to_list: "← Elenco partite"
opponent_label: Avversario opponent_label: Avversario
opponent_placeholder: "es. Volley Milano" opponent_placeholder: "es. Squadra ospite"
location_label: "Luogo (opzionale)" location_label: "Luogo (opzionale)"
location_placeholder: "Palestra, città" location_placeholder: "Palestra, città"
datetime_label: Data e ora datetime_label: Data e ora
@@ -367,7 +370,7 @@ it:
legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe." legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe."
entity_type_label: "Tipo intestatario *" entity_type_label: "Tipo intestatario *"
legal_name_label: "Ragione sociale / nome e cognome *" legal_name_label: "Ragione sociale / nome e cognome *"
legal_name_placeholder: "es. ASD Tigers Volley" legal_name_placeholder: "es. Team MLTV"
vat_number_label: "Partita IVA * (società)" vat_number_label: "Partita IVA * (società)"
fiscal_code_label: "Codice Fiscale * (persona fisica)" fiscal_code_label: "Codice Fiscale * (persona fisica)"
fiscal_code_placeholder: RSSMRA80A01H501U fiscal_code_placeholder: RSSMRA80A01H501U
@@ -771,7 +774,7 @@ it:
replay_archive_link: "Live passate — archivio replay" replay_archive_link: "Live passate — archivio replay"
schedule_match_link: "Programma partita" schedule_match_link: "Programma partita"
search_placeholder_club: "Cerca società, squadra, avversario o luogo…" search_placeholder_club: "Cerca società, squadra, avversario o luogo…"
search_placeholder_default: "Es. Crazy Volley, Serie D, avversario…" search_placeholder_default: "Es. Team MLTV, Squadra ospite…"
search_aria_label: "Cerca squadra" search_aria_label: "Cerca squadra"
search_button: "Cerca" search_button: "Cerca"
reset_link: "Azzera" reset_link: "Azzera"
@@ -805,7 +808,7 @@ it:
empty_hero_cta_features: "Scopri come funziona" empty_hero_cta_features: "Scopri come funziona"
demo_aria_label: "Esempio di diretta attiva" demo_aria_label: "Esempio di diretta attiva"
demo_label: "Esempio — così appare una diretta attiva" demo_label: "Esempio — così appare una diretta attiva"
demo_meta: "PalaTigers · Match Live TV" demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Set vinti 1-0" demo_sets: "Set 2 · Set vinti 1-0"
show: show:
back_to_all: "← Tutte le dirette" back_to_all: "← Tutte le dirette"
@@ -838,7 +841,7 @@ it:
back_to_live: "← Dirette live" back_to_live: "← Dirette live"
title: "Live passate" title: "Live passate"
hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse." hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse."
search_placeholder: "Es. Tigers Volley, avversario, società, luogo…" search_placeholder: "Es. Team MLTV, Squadra ospite…"
search_aria_label: "Cerca replay" search_aria_label: "Cerca replay"
search_button: "Cerca" search_button: "Cerca"
reset_link: "Azzera" reset_link: "Azzera"
+20
View File
@@ -0,0 +1,20 @@
it:
demo:
away_team: Squadra ospite
when: "Sabato 12 settembre · 10:30"
en:
demo:
away_team: Team Guest
when: "Saturday 12 September · 10:30"
de:
demo:
away_team: Team Guest
when: "Samstag, 12. September · 10:30"
fr:
demo:
away_team: Team Guest
when: "Samedi 12 septembre · 10:30"
es:
demo:
away_team: Team Guest
when: "Sábado 12 de septiembre · 10:30"
+17 -3
View File
@@ -59,11 +59,8 @@ de:
replay_item_download: Video bei Bedarf aufs Handy herunterladen replay_item_download: Video bei Bedarf aufs Handy herunterladen
replay_card_title: Spielarchiv replay_card_title: Spielarchiv
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen. replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34" replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22" replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15" replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Auch das gehört zum Produkt more_title: Auch das gehört zum Produkt
more_stable_title: Livestream vom Smartphone more_stable_title: Livestream vom Smartphone
@@ -76,6 +73,19 @@ de:
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig. cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
cta_primary: Kostenlos starten cta_primary: Kostenlos starten
cta_secondary: Pläne vergleichen cta_secondary: Pläne vergleichen
sponsor_cover:
eyebrow: Premium Full
title: Gib deinen Sponsoren Sichtbarkeit
body: Mit Premium Full kannst du das Titelbild jedes Spiels anpassen, indem du eine Grafik eures Vereins hochlädst. Darin könnt ihr Sponsoren, Partner, Logos und Botschaften unterbringen und ihnen schon vor dem Livestream Sichtbarkeit geben.
item_cover: Titelbild für jedes Spiel hochladen
item_before: Grafik sichtbar im Pre-Live
item_club: Die Identität eures Vereins, kein generisches Template
claim: Euer Verein. Eure Sponsoren. Euer Livestream.
cta: Premium Full entdecken
mock:
state: PRE-LIVE
art_label: Individuelles Titelbild
soon: Der Livestream beginnt in Kürze
pricing: pricing:
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV" meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube." meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
@@ -90,6 +100,7 @@ de:
table_youtube: YouTube table_youtube: YouTube
table_replay: Replay-Archiv table_replay: Replay-Archiv
table_download: Handy-Download table_download: Handy-Download
table_cover_sponsor: Individuelles Titelbild
table_price: Preis table_price: Preis
table_price_free: "€0" table_price_free: "€0"
table_price_note: "Listenpreis %{list} — %{monthly}" table_price_note: "Listenpreis %{list} — %{monthly}"
@@ -121,6 +132,7 @@ de:
youtube_mltv: Match Live TV youtube_mltv: Match Live TV
youtube_club: Vereinskanal youtube_club: Vereinskanal
youtube_none: nein youtube_none: nein
cover_sponsor_html: "Anpassbares Titelbild <strong>mit Sponsoren</strong>"
complete_billing: Rechnungsdaten vervollständigen complete_billing: Rechnungsdaten vervollständigen
start_free: Kostenlos starten start_free: Kostenlos starten
register_with_price: "Registrieren — %{price}" register_with_price: "Registrieren — %{price}"
@@ -150,6 +162,8 @@ de:
q9_question: Wie funktioniert die Garantie „Zufrieden oder Geld zurück“? q9_question: Wie funktioniert die Garantie „Zufrieden oder Geld zurück“?
q9_answer_html: "Sie können Match Live TV 30 Tage ab Aktivierung des ersten Abos mit Ihrem Verein testen. Wenn die Plattform in diesem Zeitraum nicht zu Ihren Anforderungen passt, können Sie die Erstattung des vollen gezahlten Betrags beantragen. Weitere Details finden Sie in den %{terms_link}." q9_answer_html: "Sie können Match Live TV 30 Tage ab Aktivierung des ersten Abos mit Ihrem Verein testen. Wenn die Plattform in diesem Zeitraum nicht zu Ihren Anforderungen passt, können Sie die Erstattung des vollen gezahlten Betrags beantragen. Weitere Details finden Sie in den %{terms_link}."
q9_terms_link: Nutzungsbedingungen q9_terms_link: Nutzungsbedingungen
q10_question: Kann ich die Sponsoren des Vereins in den Übertragungen zeigen?
q10_answer: Mit Premium Full kannst du ein individuelles Titelbild für das Spiel hochladen. Der Verein bereitet die eigene Grafik vor und kann darin Sponsoren, Partner, Logos und Botschaften unterbringen. Das Titelbild erscheint vor Beginn des Livestreams und gibt den Unterstützern des Clubs Sichtbarkeit.
cta_signup: Team registrieren cta_signup: Team registrieren
cta_live: Live-Spiele ansehen cta_live: Live-Spiele ansehen
volleyball: volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ en:
replay_item_download: Download the video to your phone when you need it replay_item_download: Download the video to your phone when you need it
replay_card_title: Match archive replay_card_title: Match archive
replay_card_body: Rewatch matches after the final whistle and download them when you need them. replay_card_body: Rewatch matches after the final whistle and download them when you need them.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34" replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22" replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15" replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Also part of the product more_title: Also part of the product
more_stable_title: Stream from your smartphone more_stable_title: Stream from your smartphone
@@ -76,6 +73,19 @@ en:
cta_body: Try Match Live TV for free. No dedicated equipment needed. cta_body: Try Match Live TV for free. No dedicated equipment needed.
cta_primary: Start for free cta_primary: Start for free
cta_secondary: Compare plans cta_secondary: Compare plans
sponsor_cover:
eyebrow: Premium Full
title: Give your sponsors visibility
body: With Premium Full you can customise each match cover by uploading artwork from your club. You can include sponsors, partners, logos and messages in that graphic, giving them visibility before the live stream starts.
item_cover: A cover uploaded for every match
item_before: Artwork shown in the pre-live screen
item_club: Your clubs identity, not a generic template
claim: Your club. Your sponsors. Your stream.
cta: Discover Premium Full
mock:
state: PRE-LIVE
art_label: Custom cover
soon: The live stream will start shortly
pricing: pricing:
meta_title: "Youth match streaming pricing — Match Live TV" meta_title: "Youth match streaming pricing — Match Live TV"
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube." meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
@@ -90,6 +100,7 @@ en:
table_youtube: YouTube table_youtube: YouTube
table_replay: Replay archive table_replay: Replay archive
table_download: Phone download table_download: Phone download
table_cover_sponsor: Custom cover
table_price: Price table_price: Price
table_price_free: "€0" table_price_free: "€0"
table_price_note: "list %{list} — %{monthly}" table_price_note: "list %{list} — %{monthly}"
@@ -121,6 +132,7 @@ en:
youtube_mltv: Match Live TV youtube_mltv: Match Live TV
youtube_club: club channel youtube_club: club channel
youtube_none: "no" youtube_none: "no"
cover_sponsor_html: "Customisable cover <strong>with sponsors</strong>"
complete_billing: Complete billing details complete_billing: Complete billing details
start_free: Start for free start_free: Start for free
register_with_price: "Register — %{price}" register_with_price: "Register — %{price}"
@@ -150,6 +162,8 @@ en:
q9_question: How does the Satisfied or refunded guarantee work? q9_question: How does the Satisfied or refunded guarantee work?
q9_answer_html: "You can try Match Live TV with your club for 30 days from activation of the first subscription. If within that period you feel the platform is not a good fit, you can request a refund of the full amount paid. For more details see the %{terms_link}." q9_answer_html: "You can try Match Live TV with your club for 30 days from activation of the first subscription. If within that period you feel the platform is not a good fit, you can request a refund of the full amount paid. For more details see the %{terms_link}."
q9_terms_link: Terms and Conditions q9_terms_link: Terms and Conditions
q10_question: Can I include the clubs sponsors in the broadcasts?
q10_answer: With Premium Full you can upload a custom cover for the match. The club prepares its own artwork and can include sponsors, partners, logos and messages in that graphic. The cover is shown before the live stream starts, giving visibility to the organisations that support the club.
cta_signup: Register your team cta_signup: Register your team
cta_live: Watch live matches cta_live: Watch live matches
volleyball: volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ es:
replay_item_download: Descarga del vídeo al móvil cuando haga falta replay_item_download: Descarga del vídeo al móvil cuando haga falta
replay_card_title: Archivo de partidos replay_card_title: Archivo de partidos
replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites. replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34" replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22" replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15" replay_mock_3_meta: "28/04 · 01:07:15"
more_title: También forma parte del producto more_title: También forma parte del producto
more_stable_title: Directo desde el smartphone more_stable_title: Directo desde el smartphone
@@ -76,6 +73,19 @@ es:
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado. cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
cta_primary: Empieza gratis cta_primary: Empieza gratis
cta_secondary: Compara los planes cta_secondary: Compara los planes
sponsor_cover:
eyebrow: Premium Full
title: Da visibilidad a tus patrocinadores
body: Con Premium Full puedes personalizar la portada de cada partido subiendo una gráfica de tu club. Así puedes incluir patrocinadores, partners, logotipos y mensajes, y darles visibilidad ya antes de que empiece el directo.
item_cover: Portada cargada para cada partido
item_before: Gráfica visible en el pre-directo
item_club: La identidad de tu club, no una plantilla genérica
claim: Tu club. Tus patrocinadores. Tu directo.
cta: Descubre Premium Full
mock:
state: PRE-LIVE
art_label: Portada personalizada
soon: El directo comenzará en breve
pricing: pricing:
meta_title: "Precios de streaming de partidos juveniles — Match Live TV" meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube." meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
@@ -90,6 +100,7 @@ es:
table_youtube: YouTube table_youtube: YouTube
table_replay: Archivo de replay table_replay: Archivo de replay
table_download: Descarga al móvil table_download: Descarga al móvil
table_cover_sponsor: Portada personalizada
table_price: Precio table_price: Precio
table_price_free: "€0" table_price_free: "€0"
table_price_note: "tarifa %{list} — %{monthly}" table_price_note: "tarifa %{list} — %{monthly}"
@@ -121,6 +132,7 @@ es:
youtube_mltv: Match Live TV youtube_mltv: Match Live TV
youtube_club: canal del club youtube_club: canal del club
youtube_none: "no" youtube_none: "no"
cover_sponsor_html: "Portada personalizable <strong>con patrocinadores</strong>"
complete_billing: Completar datos de facturación complete_billing: Completar datos de facturación
start_free: Empieza gratis start_free: Empieza gratis
register_with_price: "Regístrate — %{price}" register_with_price: "Regístrate — %{price}"
@@ -150,6 +162,8 @@ es:
q9_question: "¿Cómo funciona la garantía Satisfechos o reembolsados?" q9_question: "¿Cómo funciona la garantía Satisfechos o reembolsados?"
q9_answer_html: "Puedes probar Match Live TV con tu club durante 30 días desde la activación de la primera suscripción. Si en ese periodo consideras que la plataforma no se adapta a tus necesidades, puedes solicitar el reembolso del importe íntegro pagado. Para más detalles consulta los %{terms_link}." q9_answer_html: "Puedes probar Match Live TV con tu club durante 30 días desde la activación de la primera suscripción. Si en ese periodo consideras que la plataforma no se adapta a tus necesidades, puedes solicitar el reembolso del importe íntegro pagado. Para más detalles consulta los %{terms_link}."
q9_terms_link: Términos y Condiciones q9_terms_link: Términos y Condiciones
q10_question: ¿Puedo incluir a los patrocinadores del club en los directos?
q10_answer: Con Premium Full puedes subir una portada personalizada para el partido. El club prepara su propia gráfica e incluye patrocinadores, partners, logotipos y mensajes. La portada se muestra antes del inicio del directo, dando visibilidad a quienes apoyan al club.
cta_signup: Registra tu equipo cta_signup: Registra tu equipo
cta_live: Ver directos cta_live: Ver directos
volleyball: volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ fr:
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
replay_card_title: Archive des matchs replay_card_title: Archive des matchs
replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin. replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34" replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22" replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15" replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Cela fait aussi partie du produit more_title: Cela fait aussi partie du produit
more_stable_title: Direct depuis le smartphone more_stable_title: Direct depuis le smartphone
@@ -76,6 +73,19 @@ fr:
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire. cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
cta_primary: Commencer gratuitement cta_primary: Commencer gratuitement
cta_secondary: Comparer les offres cta_secondary: Comparer les offres
sponsor_cover:
eyebrow: Premium Full
title: Donnez de la visibilité à vos sponsors
body: Avec Premium Full, vous pouvez personnaliser la jaquette de chaque match en chargeant un graphisme de votre club. Vous pouvez y intégrer sponsors, partenaires, logos et messages, et leur donner de la visibilité avant le début du direct.
item_cover: Jaquette chargée pour chaque match
item_before: Graphisme visible en pré-direct
item_club: Lidentité de votre club, pas un modèle générique
claim: Votre club. Vos sponsors. Votre direct.
cta: Découvrir Premium Full
mock:
state: PRE-LIVE
art_label: Jaquette personnalisée
soon: Le direct va bientôt commencer
pricing: pricing:
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV" meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube." meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
@@ -90,6 +100,7 @@ fr:
table_youtube: YouTube table_youtube: YouTube
table_replay: Archive replay table_replay: Archive replay
table_download: Téléchargement téléphone table_download: Téléchargement téléphone
table_cover_sponsor: Jaquette personnalisée
table_price: Prix table_price: Prix
table_price_free: "€0" table_price_free: "€0"
table_price_note: "tarif %{list} — %{monthly}" table_price_note: "tarif %{list} — %{monthly}"
@@ -121,6 +132,7 @@ fr:
youtube_mltv: Match Live TV youtube_mltv: Match Live TV
youtube_club: chaîne du club youtube_club: chaîne du club
youtube_none: non youtube_none: non
cover_sponsor_html: "Jaquette personnalisable <strong>avec sponsors</strong>"
complete_billing: Compléter les données de facturation complete_billing: Compléter les données de facturation
start_free: Commencer gratuitement start_free: Commencer gratuitement
register_with_price: "S'inscrire — %{price}" register_with_price: "S'inscrire — %{price}"
@@ -150,6 +162,8 @@ fr:
q9_question: Comment fonctionne la garantie Satisfait ou remboursé ? q9_question: Comment fonctionne la garantie Satisfait ou remboursé ?
q9_answer_html: "Vous pouvez essayer Match Live TV avec votre club pendant 30 jours à compter de l'activation du premier abonnement. Si dans ce délai la plateforme ne convient pas à vos besoins, vous pouvez demander le remboursement de la totalité du montant payé. Pour plus de détails, consultez les %{terms_link}." q9_answer_html: "Vous pouvez essayer Match Live TV avec votre club pendant 30 jours à compter de l'activation du premier abonnement. Si dans ce délai la plateforme ne convient pas à vos besoins, vous pouvez demander le remboursement de la totalité du montant payé. Pour plus de détails, consultez les %{terms_link}."
q9_terms_link: Conditions générales q9_terms_link: Conditions générales
q10_question: Puis-je afficher les sponsors du club dans les directs ?
q10_answer: Avec Premium Full, vous pouvez charger une jaquette personnalisée pour le match. Le club prépare son propre graphisme et peut y intégrer sponsors, partenaires, logos et messages. La jaquette saffiche avant le début du direct, donnant de la visibilité aux réalités qui soutiennent le club.
cta_signup: Inscrire l'équipe cta_signup: Inscrire l'équipe
cta_live: Voir les directs cta_live: Voir les directs
volleyball: volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ it:
replay_item_download: Download del video sul telefono quando serve replay_item_download: Download del video sul telefono quando serve
replay_card_title: Archivio partite replay_card_title: Archivio partite
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono. replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34" replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22" replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15" replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Anche questo fa parte del prodotto more_title: Anche questo fa parte del prodotto
more_stable_title: Diretta dallo smartphone more_stable_title: Diretta dallo smartphone
@@ -76,6 +73,19 @@ it:
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata. cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
cta_primary: Inizia gratis cta_primary: Inizia gratis
cta_secondary: Confronta i piani cta_secondary: Confronta i piani
sponsor_cover:
eyebrow: Premium Full
title: Dai visibilità ai tuoi sponsor
body: Con Premium Full puoi personalizzare la copertina di ogni partita caricando una grafica della tua società. Puoi così inserire sponsor, partner, loghi e comunicazioni e dare loro visibilità già prima dellinizio della diretta.
item_cover: Copertina caricata per ogni partita
item_before: Grafica visibile nel pre-live
item_club: Identità della società, non un template generico
claim: Il tuo club. I tuoi sponsor. La tua diretta.
cta: Scopri Premium Full
mock:
state: PRE-LIVE
art_label: Copertina personalizzata
soon: La diretta inizierà a breve
pricing: pricing:
meta_title: "Prezzi streaming partite giovanili — Match Live TV" meta_title: "Prezzi streaming partite giovanili — Match Live TV"
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube." meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
@@ -90,6 +100,7 @@ it:
table_youtube: YouTube table_youtube: YouTube
table_replay: Archivio replay table_replay: Archivio replay
table_download: Download telefono table_download: Download telefono
table_cover_sponsor: Copertina personalizzata
table_price: Prezzo table_price: Prezzo
table_price_free: "€0" table_price_free: "€0"
table_price_note: "listino %{list} — %{monthly}" table_price_note: "listino %{list} — %{monthly}"
@@ -121,6 +132,7 @@ it:
youtube_mltv: Match Live TV youtube_mltv: Match Live TV
youtube_club: canale società youtube_club: canale società
youtube_none: "no" youtube_none: "no"
cover_sponsor_html: "Copertina personalizzabile <strong>con sponsor</strong>"
complete_billing: Completa dati di fatturazione complete_billing: Completa dati di fatturazione
start_free: Inizia gratis start_free: Inizia gratis
register_with_price: "Registrati — %{price}" register_with_price: "Registrati — %{price}"
@@ -150,6 +162,8 @@ it:
q9_question: Come funziona la garanzia Soddisfatti o rimborsati? q9_question: Come funziona la garanzia Soddisfatti o rimborsati?
q9_answer_html: "Puoi provare Match Live TV con la tua società per 30 giorni dall'attivazione del primo abbonamento. Se entro questo periodo ritieni che la piattaforma non sia adatta alle tue esigenze, puoi richiedere il rimborso dell'intero importo pagato. Per maggiori dettagli consulta i %{terms_link}." q9_answer_html: "Puoi provare Match Live TV con la tua società per 30 giorni dall'attivazione del primo abbonamento. Se entro questo periodo ritieni che la piattaforma non sia adatta alle tue esigenze, puoi richiedere il rimborso dell'intero importo pagato. Per maggiori dettagli consulta i %{terms_link}."
q9_terms_link: Termini e Condizioni q9_terms_link: Termini e Condizioni
q10_question: Posso inserire gli sponsor della società nelle dirette?
q10_answer: Con Premium Full puoi caricare una copertina personalizzata per la partita. La società può preparare la propria grafica inserendo sponsor, partner, loghi e comunicazioni. La copertina viene mostrata prima dellinizio della diretta, permettendo di dare visibilità alle realtà che sostengono il club.
cta_signup: Registra la squadra cta_signup: Registra la squadra
cta_live: Guarda le dirette cta_live: Guarda le dirette
volleyball: volleyball:
+1
View File
@@ -121,6 +121,7 @@ Rails.application.routes.draw do
post :regia_link post :regia_link
end end
end end
resources :stream_concurrency_violations, only: %i[index]
resources :stream_nodes, only: %i[index create destroy] do resources :stream_nodes, only: %i[index create destroy] do
member do member do
post :drain post :drain
@@ -0,0 +1,32 @@
# frozen_string_literal: true
class CreateStreamConcurrencyViolations < ActiveRecord::Migration[7.2]
def change
create_table :stream_concurrency_violations, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
t.references :user, type: :uuid, foreign_key: { on_delete: :nullify }
t.references :occupying_session, type: :uuid, foreign_key: { to_table: :stream_sessions, on_delete: :nullify }
t.references :attempted_session, type: :uuid, foreign_key: { to_table: :stream_sessions, on_delete: :nullify }
t.references :occupying_club, type: :uuid, foreign_key: { to_table: :clubs, on_delete: :nullify }
t.references :attempted_club, type: :uuid, foreign_key: { to_table: :clubs, on_delete: :nullify }
t.string :attempt_action, null: false, default: "start"
t.string :user_email, null: false
t.string :user_name
t.string :occupying_club_name
t.string :attempted_club_name
t.string :occupying_match_label, null: false
t.string :attempted_match_label, null: false
t.string :occupying_status
t.string :occupying_device
t.string :attempted_device
t.boolean :devices_differ, null: false, default: false
t.jsonb :metadata, null: false, default: {}
t.datetime :created_at, null: false
end
add_index :stream_concurrency_violations, :created_at
add_index :stream_concurrency_violations, :devices_differ
add_index :stream_sessions, %i[user_id status], name: "index_stream_sessions_on_user_id_and_status"
end
end
+36 -2
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -383,6 +383,34 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do
t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true
end end
create_table "stream_concurrency_violations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id"
t.uuid "occupying_session_id"
t.uuid "attempted_session_id"
t.uuid "occupying_club_id"
t.uuid "attempted_club_id"
t.string "attempt_action", default: "start", null: false
t.string "user_email", null: false
t.string "user_name"
t.string "occupying_club_name"
t.string "attempted_club_name"
t.string "occupying_match_label", null: false
t.string "attempted_match_label", null: false
t.string "occupying_status"
t.string "occupying_device"
t.string "attempted_device"
t.boolean "devices_differ", default: false, null: false
t.jsonb "metadata", default: {}, null: false
t.datetime "created_at", null: false
t.index ["attempted_club_id"], name: "index_stream_concurrency_violations_on_attempted_club_id"
t.index ["attempted_session_id"], name: "index_stream_concurrency_violations_on_attempted_session_id"
t.index ["created_at"], name: "index_stream_concurrency_violations_on_created_at"
t.index ["devices_differ"], name: "index_stream_concurrency_violations_on_devices_differ"
t.index ["occupying_club_id"], name: "index_stream_concurrency_violations_on_occupying_club_id"
t.index ["occupying_session_id"], name: "index_stream_concurrency_violations_on_occupying_session_id"
t.index ["user_id"], name: "index_stream_concurrency_violations_on_user_id"
end
create_table "stream_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "stream_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "stream_session_id", null: false t.uuid "stream_session_id", null: false
t.string "event_type", null: false t.string "event_type", null: false
@@ -440,9 +468,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "regia_token_digest" t.string "regia_token_digest"
t.datetime "regia_token_expires_at" t.datetime "regia_token_expires_at"
t.boolean "audio_muted", default: false, null: false
t.uuid "stream_node_id" t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false
t.string "client_os" t.string "client_os"
t.string "app_version" t.string "app_version"
t.string "app_build" t.string "app_build"
@@ -458,6 +486,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
t.index ["status"], name: "index_stream_sessions_on_status" t.index ["status"], name: "index_stream_sessions_on_status"
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id" t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
t.index ["user_id", "status"], name: "index_stream_sessions_on_user_id_and_status"
t.index ["user_id"], name: "index_stream_sessions_on_user_id" t.index ["user_id"], name: "index_stream_sessions_on_user_id"
end end
@@ -591,6 +620,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_180000) do
add_foreign_key "recordings", "stream_sessions" add_foreign_key "recordings", "stream_sessions"
add_foreign_key "recordings", "teams" add_foreign_key "recordings", "teams"
add_foreign_key "score_states", "stream_sessions" add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_concurrency_violations", "clubs", column: "attempted_club_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "clubs", column: "occupying_club_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "stream_sessions", column: "attempted_session_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "stream_sessions", column: "occupying_session_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "users", on_delete: :nullify
add_foreign_key "stream_events", "stream_sessions" add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches" add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes" add_foreign_key "stream_sessions", "stream_nodes"
+5 -5
View File
@@ -16,7 +16,7 @@ admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
u.role = "admin" u.role = "admin"
end end
club = Club.find_or_create_by!(name: "Tigers Volley") do |c| club = Club.find_or_create_by!(name: MatchLiveTv::Demo.home_team) do |c|
c.sport = "volleyball" c.sport = "volleyball"
c.primary_color = "#e53935" c.primary_color = "#e53935"
c.secondary_color = "#ffffff" c.secondary_color = "#ffffff"
@@ -24,7 +24,7 @@ end
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" } ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
team = club.teams.find_or_create_by!(name: "Under 16") do |t| team = club.teams.find_or_create_by!(name: MatchLiveTv::Demo::CATEGORY) do |t|
t.sport = "volleyball" t.sport = "volleyball"
end end
@@ -32,12 +32,12 @@ UserTeam.find_or_create_by!(user: admin, team: team) { |ut| ut.role = "member" }
Billing::AssignPlan.call(club: club, plan_slug: "free") unless club.subscription Billing::AssignPlan.call(club: club, plan_slug: "free") unless club.subscription
match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m| match = team.matches.find_or_create_by!(opponent_name: MatchLiveTv::Demo::AWAY_TEAM) do |m|
m.location = "PalaTigers - Milano" m.location = MatchLiveTv::Demo.venue
m.scheduled_at = 2.hours.from_now m.scheduled_at = 2.hours.from_now
m.sets_to_win = 3 m.sets_to_win = 3
m.roster_numbers = [4, 6, 8, 9, 10, 11, 12, 14, 16, 17, 21] m.roster_numbers = [4, 6, 8, 9, 10, 11, 12, 14, 16, 17, 21]
m.category = "U16" m.category = MatchLiveTv::Demo::CATEGORY
m.phase = "Semifinale" m.phase = "Semifinale"
end end
+6 -6
View File
@@ -12,11 +12,11 @@ coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
end end
clubs_data = [ clubs_data = [
{ name: "Tigers Volley", sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 }, { name: MatchLiveTv::Demo::HOME_TEAM, sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 },
{ name: "ASD Eagles Milano", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 }, { name: "Team MLTV Nord", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 },
{ name: "Volley Stars Roma", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 }, { name: "Team MLTV Sud", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 },
{ name: "Basket Juventus U18", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 }, { name: "Team MLTV Basket", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 },
{ name: "Padova Beach", sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 } { name: MatchLiveTv::Demo::AWAY_TEAM, sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 }
] ]
sport_keys = { sport_keys = {
@@ -81,7 +81,7 @@ clubs.each_with_index do |row, index|
started = day.change(hour: 10 + (n % 6), min: 0) started = day.change(hour: 10 + (n % 6), min: 0)
ended = started + secs.seconds ended = started + secs.seconds
match = team.matches.create!( match = team.matches.create!(
opponent_name: "Avversario demo #{n + 1}", opponent_name: MatchLiveTv::Demo::AWAY_TEAM,
sport_key: team.sport_key, sport_key: team.sport_key,
scheduled_at: started scheduled_at: started
) )
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module MatchLiveTv
# Set demo ufficiale per mockup, preview e contenuti dimostrativi del sito.
# Nomi fittizi, chiaramente riconducibili a MatchLiveTV — mai società o sponsor reali.
module Demo
HOME_TEAM = "Team MLTV"
AWAY_TEAM = "Team Guest"
CATEGORY = "U15 MASCHILE"
VENUE = "Pala MLTV"
module_function
def home_team
HOME_TEAM
end
def away_team
I18n.t("demo.away_team", default: AWAY_TEAM)
end
def category
CATEGORY
end
def when_label
I18n.t("demo.when")
end
def venue
VENUE
end
def match_title
"#{home_team} vs #{away_team}"
end
def live_meta
"#{VENUE} · Match Live TV"
end
end
end
+5
View File
@@ -236,6 +236,11 @@ body.admin-body {
.badge--ingest-cloud { background: #e65100; color: #fff3e0; } .badge--ingest-cloud { background: #e65100; color: #fff3e0; }
.badge--ended { background: #37474f; color: #eceff1; } .badge--ended { background: #37474f; color: #eceff1; }
.badge--error { background: #b71c1c; color: #ffebee; } .badge--error { background: #b71c1c; color: #ffebee; }
.badge--abuse { background: #b71c1c; color: #ffebee; }
.admin-table tr.admin-row--two-devices td {
background: rgba(183, 28, 28, 0.12);
}
.admin-ingest { .admin-ingest {
display: inline-flex; display: inline-flex;
+267
View File
@@ -3713,6 +3713,273 @@ a.replay-archive__thumb:hover {
} }
} }
.feature-card__badge--gold {
background: rgba(255, 183, 77, 0.12);
color: #ffb74d;
}
.sponsor-cover { padding-top: 8px; }
.sponsor-cover.section { padding-top: 28px; padding-bottom: 28px; }
.sponsor-cover--compact.section { padding-top: 16px; padding-bottom: 8px; }
.sponsor-cover__panel {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr);
gap: 32px;
align-items: center;
background: #14141c;
border: 1px solid #5a2a2e;
border-radius: 14px;
padding: 32px 28px;
box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.18), 0 12px 32px rgba(0, 0, 0, 0.28);
}
.sponsor-cover--compact .sponsor-cover__panel {
padding: 24px 22px;
gap: 24px;
box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.12);
}
.sponsor-cover__copy { min-width: 0; }
.sponsor-cover__copy .feature-card__badge { margin-bottom: 12px; }
.sponsor-cover__copy h2 {
text-align: left;
margin: 0 0 12px;
font-size: 1.7rem;
line-height: 1.2;
}
.sponsor-cover--compact .sponsor-cover__copy h2 { font-size: 1.4rem; }
.sponsor-cover__lead {
margin: 0 0 16px;
color: #bbb;
line-height: 1.6;
max-width: 36rem;
}
.sponsor-cover--compact .sponsor-cover__lead { margin-bottom: 18px; }
.sponsor-cover__copy .features-checklist { margin-bottom: 14px; }
.sponsor-cover__claim {
margin: 0 0 18px;
color: #ffb74d;
font-size: 0.92rem;
font-weight: 700;
letter-spacing: 0.01em;
line-height: 1.4;
}
.sponsor-cover__visual {
display: flex;
justify-content: center;
min-width: 0;
}
.cover-mock {
width: min(100%, 380px);
margin: 0;
background: #101016;
border: 1px solid #2f2f3c;
border-radius: 14px;
overflow: hidden;
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.4);
}
.sponsor-cover--compact .cover-mock { width: min(100%, 320px); }
.cover-mock__chrome {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 10px 14px;
border-bottom: 1px solid #2a2a36;
background: #16161e;
}
.cover-mock__brand {
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.08em;
color: #fff;
}
.cover-mock__brand em {
font-style: normal;
color: #e53935;
}
.cover-mock__state {
flex-shrink: 0;
padding: 3px 8px;
border-radius: 4px;
background: rgba(255, 183, 77, 0.14);
color: #ffb74d;
font-size: 0.62rem;
font-weight: 800;
letter-spacing: 0.08em;
}
.cover-mock__stage {
padding: 16px 16px 14px;
background:
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(229, 57, 53, 0.16), transparent 60%),
#101016;
text-align: center;
}
.cover-mock__cat {
margin: 0 0 8px;
color: #9a9aaa;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.cover-mock__home,
.cover-mock__away {
margin: 0;
font-size: 1.05rem;
font-weight: 800;
line-height: 1.2;
color: #fff;
}
.sponsor-cover--compact .cover-mock__home,
.sponsor-cover--compact .cover-mock__away { font-size: 0.92rem; }
.cover-mock__vs {
margin: 4px 0;
color: #e53935;
font-size: 0.72rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.cover-mock__when {
margin: 8px 0 10px;
color: #888;
font-size: 0.75rem;
}
.cover-mock__art-label {
margin: 0 0 8px;
color: #8e8e9a;
font-size: 0.62rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.cover-mock__art {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 168px;
margin-bottom: 12px;
padding: 22px 16px 16px;
border-radius: 10px;
background:
linear-gradient(155deg, rgba(229, 57, 53, 0.22) 0%, transparent 38%),
linear-gradient(180deg, #1c1418 0%, #121218 55%, #0e0e14 100%);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
}
.cover-mock__art::before {
content: "";
position: absolute;
top: -24%;
right: -18%;
width: 58%;
height: 90%;
background: linear-gradient(135deg, rgba(229, 57, 53, 0.28), transparent 70%);
transform: rotate(18deg);
pointer-events: none;
}
.sponsor-cover--compact .cover-mock__art {
min-height: 132px;
padding: 16px 14px 14px;
}
.cover-mock__crest {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cover-mock__crest-mark {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: linear-gradient(180deg, #e53935, #b71c1c);
color: #fff;
font-size: 1.15rem;
font-weight: 800;
letter-spacing: 0.02em;
box-shadow: 0 8px 18px rgba(229, 57, 53, 0.28);
}
.sponsor-cover--compact .cover-mock__crest-mark {
width: 36px;
height: 36px;
font-size: 1rem;
border-radius: 10px;
}
.cover-mock__crest-name {
color: #fff;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.cover-mock__marks {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
margin-top: 18px;
}
.cover-mock__mark {
display: block;
height: 18px;
border-radius: 4px;
opacity: 0.72;
}
.cover-mock__mark--a {
width: 46px;
background: linear-gradient(90deg, #c9a227, #f0d78c);
}
.cover-mock__mark--b {
width: 34px;
height: 18px;
border-radius: 50%;
background: #d7d7e0;
}
.cover-mock__mark--c {
width: 40px;
background: #8aa4c8;
}
.cover-mock__soon {
margin: 0;
color: #888;
font-size: 0.72rem;
font-style: italic;
}
@media (max-width: 999px) {
.sponsor-cover__panel {
grid-template-columns: 1fr;
text-align: center;
padding: 24px 20px;
}
.sponsor-cover__copy .feature-card__badge { align-self: center; }
.sponsor-cover__copy {
display: flex;
flex-direction: column;
align-items: center;
}
.sponsor-cover__copy h2 { text-align: center; }
.sponsor-cover__lead { margin-left: auto; margin-right: auto; }
.sponsor-cover__copy .features-checklist { text-align: left; width: 100%; max-width: 28rem; }
.cover-mock { width: min(100%, 360px); }
}
@media (max-width: 640px) {
.sponsor-cover__panel { padding: 20px 16px; }
.sponsor-cover__copy h2 { font-size: 1.35rem; }
.cover-mock__home,
.cover-mock__away { font-size: 0.95rem; }
.cover-mock__art { min-height: 148px; }
}
.mltv-preview-badge { .mltv-preview-badge {
position: fixed; position: fixed;
bottom: 16px; bottom: 16px;
@@ -0,0 +1,22 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe MatchLiveTv::Demo do
it "usa Team MLTV come squadra demo ufficiale" do
expect(described_class.home_team).to eq("Team MLTV")
expect(described_class.category).to eq("U15 MASCHILE")
expect(described_class.venue).to eq("Pala MLTV")
end
it "localizza l'avversario senza nomi di società reali" do
I18n.with_locale(:it) do
expect(described_class.away_team).to eq("Squadra ospite")
expect(described_class.match_title).to eq("Team MLTV vs Squadra ospite")
end
I18n.with_locale(:en) do
expect(described_class.away_team).to eq("Team Guest")
expect(described_class.match_title).to eq("Team MLTV vs Team Guest")
end
end
end
@@ -0,0 +1,47 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Admin stream concurrency violations", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-abuse", password: "Password123") }
let!(:user) { User.create!(email: "abuse@test.it", name: "Abuser", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "Abuse Club", sport: "volleyball") }
let!(:team) { club.teams.create!(name: "U16", sport: "volleyball") }
let!(:match_a) { team.matches.create!(opponent_name: "Rival A", sport: "volleyball") }
let!(:match_b) { team.matches.create!(opponent_name: "Rival B", sport: "volleyball") }
let!(:live) do
StreamSession.create!(match: match_a, user: user, platform: "matchlivetv", status: "live")
end
let!(:idle) do
StreamSession.create!(match: match_b, user: user, platform: "matchlivetv", status: "idle")
end
let!(:violation) do
StreamConcurrencyViolation.record!(attempted: idle, occupying: live, action: "start")
end
before do
post admin_login_path, params: { username: admin.username, password: "Password123" }
end
it "mostra l'elenco abusi" do
get admin_stream_concurrency_violations_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("abuse@test.it")
expect(response.body).to include("Abuse Club")
expect(response.body).to include("Rival A")
expect(response.body).to include("Rival B")
end
it "mostra il badge in dashboard" do
get admin_root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("abuse@test.it")
end
it "mostra i tentativi nella scheda società" do
get admin_club_path(club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("abuse@test.it")
expect(response.body).to include("Rival B")
end
end
@@ -0,0 +1,61 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "API start seconda diretta stesso account", type: :request do
let!(:user) { User.create!(email: "conc@test.it", name: "C", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "Conc Club", sport: "volleyball") }
let!(:membership) { club.club_memberships.create!(user: user, role: "owner") }
let!(:team) { club.teams.create!(name: "Tigers", sport: "volleyball") }
let!(:match_a) { team.matches.create!(opponent_name: "A", sport: "volleyball") }
let!(:match_b) { team.matches.create!(opponent_name: "B", sport: "volleyball") }
let!(:live) do
StreamSession.create!(
match: match_a,
user: user,
platform: "matchlivetv",
status: "live",
started_at: 5.minutes.ago,
device_model: "Pixel 8",
client_os: "android"
)
end
let!(:idle) do
StreamSession.create!(
match: match_b,
user: user,
platform: "matchlivetv",
status: "idle",
device_model: "iPhone 15",
client_os: "ios"
)
end
def auth_headers
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
token = response.parsed_body["access_token"]
{ "Authorization" => "Bearer #{token}", "Content-Type" => "application/json" }
end
before do
plan = Plan.find_or_initialize_by(slug: "premium_full")
plan.name ||= "Premium Full"
plan.features = (plan.features || {}).merge(
"platforms" => %w[matchlivetv],
"concurrent_streams_limit" => 10
)
plan.save!
club.create_subscription!(plan: plan, status: "active") if club.subscription.blank?
allow(SessionChannel).to receive(:broadcast_message)
end
it "restituisce 403 user_concurrent_stream e salva l'evidenza" do
patch "/api/v1/sessions/#{idle.id}/start", headers: auth_headers
expect(response).to have_http_status(:forbidden)
body = response.parsed_body
expect(body["error_code"]).to eq("user_concurrent_stream")
expect(body["error"]).to be_present
expect(StreamConcurrencyViolation.where(attempted_session_id: idle.id, occupying_session_id: live.id)).to exist
end
end
@@ -16,7 +16,17 @@ RSpec.describe "Public features page", type: :request do
expect(response.body).to include("Chi filma, filma") expect(response.body).to include("Chi filma, filma")
expect(response.body).to include("Accesso con link") expect(response.body).to include("Accesso con link")
expect(response.body).to include("La diretta resta della tua società") expect(response.body).to include("La diretta resta della tua società")
expect(response.body).to include("La partita finisce. Il ricordo resta.") expect(response.body).to include("Dai visibilità ai tuoi sponsor")
expect(response.body).to include("Copertina caricata per ogni partita")
expect(response.body).to include("Scopri Premium Full")
expect(response.body).to include("Team MLTV")
expect(response.body).to include("Squadra ospite")
expect(response.body).to include("Copertina personalizzata")
expect(response.body).not_to include("Lupi Santa Croce")
expect(response.body).not_to include("Rossi vs Neri")
expect(response.body).not_to include("Tigers Volley")
expect(response.body).not_to include("MAIN SPONSOR")
expect(response.body).not_to include("CON IL SUPPORTO DI")
expect(response.body).to include("Inizia gratis") expect(response.body).to include("Inizia gratis")
expect(response.body).to include("Confronta i piani") expect(response.body).to include("Confronta i piani")
expect(response.body).to include(public_signup_path) expect(response.body).to include(public_signup_path)
@@ -10,6 +10,10 @@ RSpec.describe "Public home page", type: :request do
expect(response.body).to include(I18n.t("home.stores_label", locale: :it)) expect(response.body).to include(I18n.t("home.stores_label", locale: :it))
expect(response.body).to include(I18n.t("home.store_app_store_name", locale: :it)) expect(response.body).to include(I18n.t("home.store_app_store_name", locale: :it))
expect(response.body).to include(I18n.t("home.store_play_name", locale: :it)) expect(response.body).to include(I18n.t("home.store_play_name", locale: :it))
expect(response.body).to include("Dai visibilità ai tuoi sponsor")
expect(response.body).to include("Team MLTV")
expect(response.body).not_to include("Lupi Santa Croce")
expect(response.body).to include("Scopri Premium Full")
expect(response.body.scan(MatchLiveTv.app_store_url).size).to eq(3) expect(response.body.scan(MatchLiveTv.app_store_url).size).to eq(3)
expect(response.body.scan(MatchLiveTv.play_store_url).size).to eq(3) expect(response.body.scan(MatchLiveTv.play_store_url).size).to eq(3)
end end
@@ -26,6 +26,14 @@ RSpec.describe "Public pricing page", type: :request do
expect(response.body).to include("Account trasmettitori") expect(response.body).to include("Account trasmettitori")
expect(response.body).to include("Niente password condivise") expect(response.body).to include("Niente password condivise")
expect(response.body).to include("plan-card--featured") expect(response.body).to include("plan-card--featured")
expect(response.body).to include("Copertina personalizzabile")
expect(response.body).to include("con sponsor")
expect(response.body).to include("Copertina personalizzata")
expect(response.body).to include("Dai visibilità ai tuoi sponsor")
expect(response.body).to include("Team MLTV")
expect(response.body).not_to include("Lupi Santa Croce")
expect(response.body).not_to include("MAIN SPONSOR")
expect(response.body).not_to include("CON IL SUPPORTO DI")
expect(response.body).to include("Soddisfatti o rimborsati") expect(response.body).to include("Soddisfatti o rimborsati")
expect(response.body).to include("30 giorni soddisfatti o rimborsati") expect(response.body).to include("30 giorni soddisfatti o rimborsati")
expect(response.body).to include("ti rimborsiamo il 100%") expect(response.body).to include("ti rimborsiamo il 100%")
+11 -11
View File
@@ -1,12 +1,12 @@
require "rails_helper" require "rails_helper"
RSpec.describe "Public team page", type: :request do RSpec.describe "Public team page", type: :request do
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "Team MLTV", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) do let!(:team) do
club.teams.create!( club.teams.create!(
name: "Tigers Volley U17", name: "Team MLTV U15",
sport_key: "pallavolo", sport_key: "pallavolo",
description: "Squadra giovanile under 17.", description: "Squadra giovanile under 15.",
roster_public: true roster_public: true
) )
end end
@@ -15,9 +15,9 @@ RSpec.describe "Public team page", type: :request do
get public_team_page_path(team.slug) get public_team_page_path(team.slug)
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("Tigers Volley U17") expect(response.body).to include("Team MLTV U15")
expect(response.body).to include("ASD Eagles") expect(response.body).to include("Team MLTV")
expect(response.body).to include("Squadra giovanile under 17") expect(response.body).to include("Squadra giovanile under 15")
end end
it "restituisce 404 per slug inesistente" do it "restituisce 404 per slug inesistente" do
@@ -41,13 +41,13 @@ RSpec.describe "Public team page", type: :request do
end end
RSpec.describe "Public team directory", type: :request do RSpec.describe "Public team directory", type: :request do
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "Team MLTV", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let(:user) { User.create!(email: "dir2@test.com", name: "Coach", password: "Password123", role: "coach") } let(:user) { User.create!(email: "dir2@test.com", name: "Coach", password: "Password123", role: "coach") }
let!(:team) { club.teams.create!(name: "Tigers Volley U17", sport_key: "pallavolo") } let!(:team) { club.teams.create!(name: "Team MLTV U15", sport_key: "pallavolo") }
before do before do
club.club_memberships.create!(user: user, role: "owner") club.club_memberships.create!(user: user, role: "owner")
match = team.matches.create!(opponent_name: "Rival", sport_key: "pallavolo", scheduled_at: 1.day.from_now) match = team.matches.create!(opponent_name: MatchLiveTv::Demo::AWAY_TEAM, sport_key: "pallavolo", scheduled_at: 1.day.from_now)
StreamSession.create!( StreamSession.create!(
match: match, user: user, status: "live", platform: "matchlivetv", match: match, user: user, status: "live", platform: "matchlivetv",
privacy_status: "public", publish_token: "tok-dir", started_at: Time.current privacy_status: "public", publish_token: "tok-dir", started_at: Time.current
@@ -58,13 +58,13 @@ RSpec.describe "Public team directory", type: :request do
get public_team_pages_path get public_team_pages_path
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("Tigers Volley U17") expect(response.body).to include("Team MLTV U15")
expect(response.body).to include("In diretta") expect(response.body).to include("In diretta")
end end
it "filtra per sport via query string" do it "filtra per sport via query string" do
get public_team_pages_path(sport: "basket") get public_team_pages_path(sport: "basket")
expect(response.body).not_to include("Tigers Volley U17") expect(response.body).not_to include("Team MLTV U15")
end end
end end
@@ -0,0 +1,99 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe Sessions::Start, "one live per user" do
let(:user) { User.create!(email: "tx@test.it", name: "Tx", password: "Password123", role: "coach") }
let(:club) { Club.create!(name: "Club A", sport: "volleyball") }
let(:team) { club.teams.create!(name: "Team A", sport: "volleyball") }
let(:match_a) { team.matches.create!(opponent_name: "Opp A", sport: "volleyball") }
let(:match_b) { team.matches.create!(opponent_name: "Opp B", sport: "volleyball") }
before do
club.club_memberships.create!(user: user, role: "owner")
plan = Plan.find_or_initialize_by(slug: "premium_full")
plan.name ||= "Premium Full"
plan.features = (plan.features || {}).merge(
"platforms" => %w[matchlivetv youtube],
"concurrent_streams_limit" => 10
)
plan.save!
club.create_subscription!(plan: plan, status: "active") if club.subscription.blank?
allow(SessionChannel).to receive(:broadcast_message)
end
def session_for(match, status:, device_model: "Pixel 8")
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: status,
device_manufacturer: "Google",
device_model: device_model,
client_os: "android"
)
end
it "avvia la prima diretta" do
session = session_for(match_a, status: "idle")
described_class.new(session).call
expect(session.reload.status).to eq("connecting")
expect(StreamConcurrencyViolation.count).to eq(0)
end
it "blocca la seconda diretta dello stesso account e registra l'abuso" do
live = session_for(match_a, status: "live", device_model: "Pixel 8")
live.update!(started_at: 10.minutes.ago)
second = session_for(match_b, status: "idle", device_model: "iPhone 15")
second.update!(client_os: "ios", device_manufacturer: "Apple")
expect {
described_class.new(second).call
}.to raise_error(Teams::EntitlementError) { |e|
expect(e.code).to eq("user_concurrent_stream")
}
expect(second.reload.status).to eq("idle")
violation = StreamConcurrencyViolation.last
expect(violation).to be_present
expect(violation.user_email).to eq(user.email)
expect(violation.occupying_session_id).to eq(live.id)
expect(violation.attempted_session_id).to eq(second.id)
expect(violation.devices_differ).to be(true)
expect(violation.attempt_action).to eq("start")
end
it "non blocca un secondo account sulla stessa società" do
other = User.create!(email: "tx2@test.it", name: "Tx2", password: "Password123", role: "coach")
club.club_memberships.create!(user: other, role: "owner")
session_for(match_a, status: "live")
other_session = StreamSession.create!(
match: match_b,
user: other,
platform: "matchlivetv",
status: "idle"
)
described_class.new(other_session).call
expect(other_session.reload.status).to eq("connecting")
end
it "considera occupante anche una sessione in pausa" do
session_for(match_a, status: "paused")
second = session_for(match_b, status: "idle")
expect {
described_class.new(second).call
}.to raise_error(Teams::EntitlementError) { |e|
expect(e.code).to eq("user_concurrent_stream")
}
end
it "non considera occupante una sessione idle" do
session_for(match_a, status: "idle")
second = session_for(match_b, status: "idle")
described_class.new(second).call
expect(second.reload.status).to eq("connecting")
end
end
@@ -4,13 +4,13 @@ RSpec.describe Teams::GenerateSlug do
let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
it "genera uno slug dal nome" do it "genera uno slug dal nome" do
team = club.teams.build(name: "Crazy Volley U17B", sport_key: "pallavolo") team = club.teams.build(name: "Team MLTV U15", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("crazy-volley-u17b") expect(described_class.call(team)).to eq("team-mltv-u15")
end end
it "aggiunge un suffisso se lo slug esiste già" do it "aggiunge un suffisso se lo slug esiste già" do
club.teams.create!(name: "Tigers", sport_key: "pallavolo") club.teams.create!(name: "Team MLTV", sport_key: "pallavolo")
team = club.teams.build(name: "Tigers", sport_key: "pallavolo") team = club.teams.build(name: "Team MLTV", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("tigers-2") expect(described_class.call(team)).to eq("team-mltv-2")
end end
end end
@@ -14,6 +14,8 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import retrofit2.HttpException import retrofit2.HttpException
class SessionApiException(message: String, cause: Throwable) : Exception(message, cause)
class SessionRepository( class SessionRepository(
private val api: MatchLiveApi, private val api: MatchLiveApi,
private val appContext: Context, private val appContext: Context,
@@ -54,17 +56,15 @@ class SessionRepository(
} }
} }
private fun parseErrorCode(error: HttpException): String? {
val body = error.response()?.errorBody()?.string().orEmpty()
if (body.isBlank()) return null
return runCatching { errorAdapter.fromJson(body)?.errorCode }.getOrNull()
}
suspend fun fetchSession(sessionId: String): StreamSession = suspend fun fetchSession(sessionId: String): StreamSession =
api.session(sessionId).toDomain() api.session(sessionId).toDomain()
suspend fun startSession(sessionId: String): StreamSession = suspend fun startSession(sessionId: String): StreamSession =
api.startSession(sessionId).toDomain() try {
api.startSession(sessionId).toDomain()
} catch (e: HttpException) {
throw SessionApiException(parseErrorMessage(e), e)
}
suspend fun stopSession(sessionId: String): StreamSession = suspend fun stopSession(sessionId: String): StreamSession =
api.stopSession(sessionId).toDomain() api.stopSession(sessionId).toDomain()
@@ -73,7 +73,11 @@ class SessionRepository(
api.pauseSession(sessionId).toDomain() api.pauseSession(sessionId).toDomain()
suspend fun resumeSession(sessionId: String): StreamSession = suspend fun resumeSession(sessionId: String): StreamSession =
api.resumeSession(sessionId).toDomain() try {
api.resumeSession(sessionId).toDomain()
} catch (e: HttpException) {
throw SessionApiException(parseErrorMessage(e), e)
}
suspend fun setAudioMute(sessionId: String, muted: Boolean): StreamSession = suspend fun setAudioMute(sessionId: String, muted: Boolean): StreamSession =
api.setAudioMute(sessionId, com.matchlivetv.match_live_tv.data.api.AudioMuteRequest(muted)).toDomain() api.setAudioMute(sessionId, com.matchlivetv.match_live_tv.data.api.AudioMuteRequest(muted)).toDomain()
@@ -121,4 +125,19 @@ class SessionRepository(
), ),
) )
} }
private fun parseErrorCode(error: HttpException): String? {
return parseError(error)?.errorCode
}
private fun parseErrorMessage(error: HttpException): String {
val parsed = parseError(error)
return parsed?.error?.takeIf { it.isNotBlank() } ?: error.message() ?: "HTTP ${error.code()}"
}
private fun parseError(error: HttpException): ApiErrorResponse? {
val body = error.response()?.errorBody()?.string().orEmpty()
if (body.isBlank()) return null
return runCatching { errorAdapter.fromJson(body) }.getOrNull()
}
} }
@@ -6,8 +6,8 @@ final class MatchScoringRulesTests: XCTestCase {
Match( Match(
id: "match-1", id: "match-1",
teamId: "team-1", teamId: "team-1",
teamName: "Tigers Volley", teamName: "Team MLTV",
opponentName: "Avversario", opponentName: "Team Guest",
location: nil, location: nil,
scheduledAt: nil, scheduledAt: nil,
sportKey: "pallavolo", sportKey: "pallavolo",