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" }
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
end
def grant_comped
@@ -8,6 +8,8 @@ module Admin
.includes(:stream_node, match: :team)
.order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8)
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
end
def metrics
@@ -18,6 +18,7 @@ module Admin
.find(params[:id])
@events = @session.stream_events.recent.limit(100)
@club = @session.match.team.club
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
end
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.presence&.join(" · ") || I18n.t("admin.common.dash")
end
def admin_concurrency_violation_lookback_count
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
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 :stream_node, optional: true
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 :recording
has_many :device_states, dependent: :destroy
@@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord
stream_node&.role.presence || ingest_role
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
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# 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 :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
has_many :stream_sessions, dependent: :nullify
has_many :stream_concurrency_violations, dependent: :nullify
def manageable_teams
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
cancel_timeout_job
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
@session.begin_connect! if @session.may_begin_connect?
Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
# 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).
log_event("resumed")
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
+5 -3
View File
@@ -5,9 +5,11 @@ module Sessions
end
def call
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@session.begin_connect! if @session.may_begin_connect?
@session.update!(status: "connecting") unless @session.connecting?
Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
@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"
broadcast_status("connecting")
@session
@@ -46,3 +46,14 @@
</tbody>
</table>
<% 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 %>
</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));">
<div class="chart-card">
<h3><%= t("admin.dashboard.disk.system_title") %></h3>
@@ -258,6 +258,16 @@
</section>
<% 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">
<h3><%= t("admin.sessions.show.events_title") %></h3>
<% 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="robots" content="noindex, nofollow">
<%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=15">
<link rel="stylesheet" href="/admin.css?v=16">
<%= yield :head %>
<% if content_for?(:replay_archive_styles) %>
<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.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") %>
<% 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.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") %>
+1 -1
View File
@@ -10,7 +10,7 @@
<%= render "shared/analytics_suppress" %>
<%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=79">
<link rel="stylesheet" href="/marketing.css?v=81">
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %>
<%= 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="/marketing.css?v=79">
<link rel="stylesheet" href="/marketing.css?v=81">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
+2 -2
View File
@@ -164,8 +164,8 @@
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
<p class="demo-label"><%= t("live.index.demo_label") %></p>
<article class="live-card live-card--demo">
<h3>Tigers Volley vs ASD Eagles</h3>
<p class="meta"><%= t("live.index.demo_meta") %></p>
<h3><%= MatchLiveTv::Demo.match_title %></h3>
<p class="meta"><%= MatchLiveTv::Demo.live_meta %></p>
<p class="card-score">
<span class="card-sets"><%= t("live.index.demo_sets") %></span>
<span class="card-points">18 - 16</span>
@@ -80,6 +80,13 @@
) %>
</p>
</details>
<details class="faq-item">
<summary><%= t("pages.faq.q10_question") %></summary>
<p>
<%= t("pages.faq.q10_answer") %>
</p>
</details>
</div>
<script>
(function () {
@@ -187,7 +187,7 @@
<div class="features-yt-mock__meta">
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
<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>
</div>
</div>
@@ -196,6 +196,8 @@
</div>
</section>
<%= render "shared/sponsor_cover_promo" %>
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
<div class="features-split features-split--reverse">
<div class="features-split__copy">
@@ -218,7 +220,7 @@
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
<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>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -226,7 +228,7 @@
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
<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>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -234,7 +236,7 @@
<li>
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
<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>
</span>
<i class="fa-solid fa-download" aria-hidden="true"></i>
@@ -94,6 +94,8 @@
</div>
</section>
<%= render "shared/sponsor_cover_promo", variant: :compact %>
<section class="section wrap plans-teaser">
<h2><%= t("home.plans_title") %></h2>
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
@@ -22,6 +22,8 @@
<%= render "shared/plan_cards" %>
<%= render "shared/sponsor_cover_promo", variant: :compact, show_cta: false, nested: true %>
<div class="table-scroll compare-table-wrap">
<table class="compare-table">
<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_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_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>
<td><%= t("pages.pricing.table_price") %></td>
<td><%= t("pages.pricing.table_price_free") %></td>
@@ -65,6 +65,9 @@
t("pages.plans.youtube_none")
end
) %></li>
<% if plan.slug == "premium_full" %>
<li><%= raw t("pages.plans.cover_sponsor_html") %></li>
<% end %>
</ul>
<% if plan.slug == "premium_full" %>
<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
youtube: YouTube
sessions: Sitzungen
stream_concurrency: Konto-Missbrauch
analytics: Analytics
costs: Kosten
stream_nodes: Stream-Knoten
@@ -103,6 +104,12 @@ de:
warnings: "%{count} Warnung(en)"
dashboard_link: Ops-Dashboard
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:
system_title: Systemfestplatte
free_label: "Frei: %{free} (%{percent}% belegt)"
@@ -193,6 +200,36 @@ de:
table:
resolved_at: 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:
index:
title: Vereine & Teams
@@ -224,6 +261,10 @@ de:
sport: Sportart
matches_and_details: Spiele & Details
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:
title: Kostenloses Abonnement
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
@@ -296,6 +337,8 @@ de:
devices_title: Geräte
events_title: Ereignisse
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:"
stop_button: Sitzung beenden
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
youtube: YouTube
sessions: Sessions
stream_concurrency: Account abuse
analytics: Analytics
costs: Costs
stream_nodes: Stream nodes
@@ -103,6 +104,12 @@ en:
warnings: "%{count} warning(s)"
dashboard_link: Ops dashboard
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:
system_title: System disk
free_label: "Free: %{free} (%{percent}% used)"
@@ -193,6 +200,36 @@ en:
table:
resolved_at: Resolved
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:
index:
title: Clubs & teams
@@ -224,6 +261,10 @@ en:
sport: Sport
matches_and_details: Matches & details
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:
title: Complimentary subscription
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
@@ -296,6 +337,8 @@ en:
devices_title: Devices
events_title: Events
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:"
stop_button: End session
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
youtube: YouTube
sessions: Sesiones
stream_concurrency: Abuso de cuenta
analytics: Analytics
costs: Costes
stream_nodes: Nodos stream
@@ -103,6 +104,12 @@ es:
warnings: "%{count} aviso(s)"
dashboard_link: Panel de Ops
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:
system_title: Disco del sistema
free_label: "Libre: %{free} (%{percent}% usado)"
@@ -193,6 +200,36 @@ es:
table:
resolved_at: Resuelta
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:
index:
title: Clubes y equipos
@@ -224,6 +261,10 @@ es:
sport: Deporte
matches_and_details: Partidos y detalles
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:
title: Suscripción de cortesía
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
events_title: Eventos
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:"
stop_button: Finalizar sesión
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
youtube: YouTube
sessions: Sessions
stream_concurrency: Abus de compte
analytics: Analytics
costs: Coûts
stream_nodes: Nœuds stream
@@ -103,6 +104,12 @@ fr:
warnings: "%{count} avertissement(s)"
dashboard_link: Tableau de bord Ops
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:
system_title: Disque système
free_label: "Libre : %{free} (%{percent}% utilisé)"
@@ -193,6 +200,36 @@ fr:
table:
resolved_at: Résolu
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:
index:
title: Clubs et équipes
@@ -224,6 +261,10 @@ fr:
sport: Sport
matches_and_details: Matchs et détails
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:
title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
@@ -296,6 +337,8 @@ fr:
devices_title: Appareils
events_title: Événements
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 :"
stop_button: Terminer la session
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
youtube: YouTube
sessions: Sessioni
stream_concurrency: Abusi account
analytics: Analytics
costs: Costi
stream_nodes: Nodi stream
@@ -107,6 +108,12 @@ it:
warnings: "%{count} warning"
dashboard_link: Dashboard Ops
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:
system_title: Disco sistema
free_label: "Libero: %{free} (%{percent}% usato)"
@@ -197,6 +204,36 @@ it:
table:
resolved_at: Risolto
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:
index:
title: Società e squadre
@@ -229,6 +266,10 @@ it:
sport: Sport
matches_and_details: Partite e dettagli
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:
title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
@@ -317,6 +358,8 @@ it:
devices_title: Dispositivi
events_title: Eventi
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:"
stop_button: Termina sessione
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:
api:
errors:
user_concurrent_stream: "Mit diesem Konto läuft bereits eine Direktübertragung. Beende sie, bevor du eine weitere startest."
password_policy:
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
activerecord:
@@ -77,10 +80,10 @@ de:
title: "Verein registrieren — Match Live TV"
meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV.
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
name_label: Vereinsname / Club
name_placeholder: "z. B. Crazy Volley Rozzano"
name_placeholder: "z. B. Team MLTV"
section_first_team: Erstes Team
default_first_team_name: Erstes Team
first_team_name_label: Teamname
@@ -159,7 +162,7 @@ de:
heading: Neues Team
club_label: "Verein:"
name_label: Teamname
name_placeholder: "z. B. Under 13, Serie C"
name_placeholder: "z. B. U15 männlich"
branding_legend: Branding-Überschreibung (optional)
submit: Team hinzufügen
invite:
@@ -235,7 +238,7 @@ de:
matches:
back_to_list: "← Spieleliste"
opponent_label: Gegner
opponent_placeholder: "z. B. Volley Milano"
opponent_placeholder: "z. B. Team Guest"
location_label: "Ort (optional)"
location_placeholder: "Halle, Stadt"
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."
entity_type_label: "Art des Rechnungsempfängers *"
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)"
fiscal_code_label: "Steuernummer * (Privatperson)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ de:
replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv"
schedule_match_link: "Spiel planen"
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_button: "Suchen"
reset_link: "Zurücksetzen"
@@ -772,7 +775,7 @@ de:
empty_hero_cta_features: "Erfahre, wie es funktioniert"
demo_aria_label: "Beispiel einer aktiven Live-Übertragung"
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"
show:
back_to_all: "← Alle Live-Übertragungen"
@@ -805,7 +808,7 @@ de:
back_to_live: "← Live-Übertragungen"
title: "Vergangene Live-Übertragungen"
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_button: "Suchen"
reset_link: "Zurücksetzen"
+11 -8
View File
@@ -1,4 +1,7 @@
en:
api:
errors:
user_concurrent_stream: "You already have a live stream running on this account. Stop it before starting another."
password_policy:
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
activerecord:
@@ -82,10 +85,10 @@ en:
title: "Register a club — Match Live TV"
meta_description: Create your sports club and its first team on Match Live TV.
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
name_label: Club name
name_placeholder: "e.g. Crazy Volley Rozzano"
name_placeholder: "e.g. Team MLTV"
section_first_team: First team
default_first_team_name: First team
first_team_name_label: Team name
@@ -164,7 +167,7 @@ en:
heading: New team
club_label: "Club:"
name_label: Team name
name_placeholder: "e.g. Under 13, Serie C"
name_placeholder: "e.g. U15 boys"
branding_legend: Branding override (optional)
submit: Add team
invite:
@@ -240,7 +243,7 @@ en:
matches:
back_to_list: "← Match list"
opponent_label: Opponent
opponent_placeholder: "e.g. Volley Milano"
opponent_placeholder: "e.g. Team Guest"
location_label: "Location (optional)"
location_placeholder: "Gym, city"
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."
entity_type_label: "Billing entity type *"
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)"
fiscal_code_label: "Fiscal code * (private individual)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -743,7 +746,7 @@ en:
replay_archive_link: "Past live streams — replay archive"
schedule_match_link: "Schedule a match"
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_button: "Search"
reset_link: "Reset"
@@ -777,7 +780,7 @@ en:
empty_hero_cta_features: "See how it works"
demo_aria_label: "Example of an active live stream"
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"
show:
back_to_all: "← All live streams"
@@ -810,7 +813,7 @@ en:
back_to_live: "← Live streams"
title: "Past live streams"
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_button: "Search"
reset_link: "Reset"
+11 -8
View File
@@ -1,4 +1,7 @@
es:
api:
errors:
user_concurrent_stream: "Ya tienes un directo en curso con esta cuenta. Ciérralo antes de iniciar otro."
password_policy:
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
activerecord:
@@ -77,10 +80,10 @@ es:
title: "Registrar un club — Match Live TV"
meta_description: Crea tu club deportivo y su primer equipo en Match Live TV.
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
name_label: Nombre del club
name_placeholder: "ej. Crazy Volley Rozzano"
name_placeholder: "ej. Team MLTV"
section_first_team: Primer equipo
default_first_team_name: Primer equipo
first_team_name_label: Nombre del equipo
@@ -159,7 +162,7 @@ es:
heading: Nuevo equipo
club_label: "Club:"
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)
submit: Añadir equipo
invite:
@@ -235,7 +238,7 @@ es:
matches:
back_to_list: "← Lista de partidos"
opponent_label: Rival
opponent_placeholder: "ej. Volley Milano"
opponent_placeholder: "ej. Team Guest"
location_label: "Lugar (opcional)"
location_placeholder: "Pabellón, ciudad"
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."
entity_type_label: "Tipo de titular *"
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)"
fiscal_code_label: "Código fiscal * (persona física)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ es:
replay_archive_link: "Directos pasados — archivo de repeticiones"
schedule_match_link: "Programar partido"
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_button: "Buscar"
reset_link: "Restablecer"
@@ -772,7 +775,7 @@ es:
empty_hero_cta_features: "Descubre cómo funciona"
demo_aria_label: "Ejemplo de 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"
show:
back_to_all: "← Todos los directos"
@@ -805,7 +808,7 @@ es:
back_to_live: "← Directos en curso"
title: "Directos pasados"
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_button: "Buscar"
reset_link: "Restablecer"
+11 -8
View File
@@ -1,4 +1,7 @@
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:
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
activerecord:
@@ -77,10 +80,10 @@ fr:
title: "Inscrire un club — Match Live TV"
meta_description: Crée ton club sportif et sa première équipe sur Match Live TV.
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
name_label: Nom du club
name_placeholder: "ex. Crazy Volley Rozzano"
name_placeholder: "ex. Team MLTV"
section_first_team: Première équipe
default_first_team_name: Première équipe
first_team_name_label: Nom de l'équipe
@@ -159,7 +162,7 @@ fr:
heading: Nouvelle équipe
club_label: "Club :"
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)
submit: Ajouter l'équipe
invite:
@@ -235,7 +238,7 @@ fr:
matches:
back_to_list: "← Liste des matchs"
opponent_label: Adversaire
opponent_placeholder: "ex. Volley Milano"
opponent_placeholder: "ex. Team Guest"
location_label: "Lieu (facultatif)"
location_placeholder: "Gymnase, ville"
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."
entity_type_label: "Type de titulaire *"
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)"
fiscal_code_label: "Code fiscal * (personne physique)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ fr:
replay_archive_link: "Directs passés — archive des replays"
schedule_match_link: "Programmer un match"
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_button: "Rechercher"
reset_link: "Réinitialiser"
@@ -772,7 +775,7 @@ fr:
empty_hero_cta_features: "Découvrez comment ça marche"
demo_aria_label: "Exemple de 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"
show:
back_to_all: "← Tous les directs"
@@ -805,7 +808,7 @@ fr:
back_to_live: "← Directs en cours"
title: "Directs passé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_button: "Rechercher"
reset_link: "Réinitialiser"
+11 -8
View File
@@ -1,4 +1,7 @@
it:
api:
errors:
user_concurrent_stream: "Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra."
password_policy:
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
activerecord:
@@ -82,10 +85,10 @@ it:
title: "Registra società — Match Live TV"
meta_description: Crea la società sportiva e la prima squadra su Match Live TV.
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à
name_label: Nome società / club
name_placeholder: "es. Crazy Volley Rozzano"
name_placeholder: "es. Team MLTV"
section_first_team: Prima squadra
default_first_team_name: Prima squadra
first_team_name_label: Nome squadra
@@ -164,7 +167,7 @@ it:
heading: Nuova squadra
club_label: "Società:"
name_label: Nome squadra
name_placeholder: "es. Under 13, Serie C"
name_placeholder: "es. U15 maschile"
branding_legend: Override branding (opzionale)
submit: Aggiungi squadra
invite:
@@ -240,7 +243,7 @@ it:
matches:
back_to_list: "← Elenco partite"
opponent_label: Avversario
opponent_placeholder: "es. Volley Milano"
opponent_placeholder: "es. Squadra ospite"
location_label: "Luogo (opzionale)"
location_placeholder: "Palestra, città"
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."
entity_type_label: "Tipo intestatario *"
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à)"
fiscal_code_label: "Codice Fiscale * (persona fisica)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -771,7 +774,7 @@ it:
replay_archive_link: "Live passate — archivio replay"
schedule_match_link: "Programma partita"
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_button: "Cerca"
reset_link: "Azzera"
@@ -805,7 +808,7 @@ it:
empty_hero_cta_features: "Scopri come funziona"
demo_aria_label: "Esempio di 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"
show:
back_to_all: "← Tutte le dirette"
@@ -838,7 +841,7 @@ it:
back_to_live: "← Dirette live"
title: "Live passate"
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_button: "Cerca"
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_card_title: Spielarchiv
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_2_title: Rossi vs Blu
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"
more_title: Auch das gehört zum Produkt
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_primary: Kostenlos starten
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:
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."
@@ -90,6 +100,7 @@ de:
table_youtube: YouTube
table_replay: Replay-Archiv
table_download: Handy-Download
table_cover_sponsor: Individuelles Titelbild
table_price: Preis
table_price_free: "€0"
table_price_note: "Listenpreis %{list} — %{monthly}"
@@ -121,6 +132,7 @@ de:
youtube_mltv: Match Live TV
youtube_club: Vereinskanal
youtube_none: nein
cover_sponsor_html: "Anpassbares Titelbild <strong>mit Sponsoren</strong>"
complete_billing: Rechnungsdaten vervollständigen
start_free: Kostenlos starten
register_with_price: "Registrieren — %{price}"
@@ -150,6 +162,8 @@ de:
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_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_live: Live-Spiele ansehen
volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ en:
replay_item_download: Download the video to your phone when you need it
replay_card_title: Match archive
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_2_title: Rossi vs Blu
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"
more_title: Also part of the product
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_primary: Start for free
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:
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."
@@ -90,6 +100,7 @@ en:
table_youtube: YouTube
table_replay: Replay archive
table_download: Phone download
table_cover_sponsor: Custom cover
table_price: Price
table_price_free: "€0"
table_price_note: "list %{list} — %{monthly}"
@@ -121,6 +132,7 @@ en:
youtube_mltv: Match Live TV
youtube_club: club channel
youtube_none: "no"
cover_sponsor_html: "Customisable cover <strong>with sponsors</strong>"
complete_billing: Complete billing details
start_free: Start for free
register_with_price: "Register — %{price}"
@@ -150,6 +162,8 @@ en:
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_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_live: Watch live matches
volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ es:
replay_item_download: Descarga del vídeo al móvil cuando haga falta
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_mock_1_title: Rossi vs Neri
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_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: También forma parte del producto
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_primary: Empieza gratis
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:
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."
@@ -90,6 +100,7 @@ es:
table_youtube: YouTube
table_replay: Archivo de replay
table_download: Descarga al móvil
table_cover_sponsor: Portada personalizada
table_price: Precio
table_price_free: "€0"
table_price_note: "tarifa %{list} — %{monthly}"
@@ -121,6 +132,7 @@ es:
youtube_mltv: Match Live TV
youtube_club: canal del club
youtube_none: "no"
cover_sponsor_html: "Portada personalizable <strong>con patrocinadores</strong>"
complete_billing: Completar datos de facturación
start_free: Empieza gratis
register_with_price: "Regístrate — %{price}"
@@ -150,6 +162,8 @@ es:
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_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_live: Ver directos
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_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_mock_1_title: Rossi vs Neri
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_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Cela fait aussi partie du produit
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_primary: Commencer gratuitement
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:
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."
@@ -90,6 +100,7 @@ fr:
table_youtube: YouTube
table_replay: Archive replay
table_download: Téléchargement téléphone
table_cover_sponsor: Jaquette personnalisée
table_price: Prix
table_price_free: "€0"
table_price_note: "tarif %{list} — %{monthly}"
@@ -121,6 +132,7 @@ fr:
youtube_mltv: Match Live TV
youtube_club: chaîne du club
youtube_none: non
cover_sponsor_html: "Jaquette personnalisable <strong>avec sponsors</strong>"
complete_billing: Compléter les données de facturation
start_free: Commencer gratuitement
register_with_price: "S'inscrire — %{price}"
@@ -150,6 +162,8 @@ fr:
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_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_live: Voir les directs
volleyball:
+17 -3
View File
@@ -59,11 +59,8 @@ it:
replay_item_download: Download del video sul telefono quando serve
replay_card_title: Archivio partite
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_2_title: Rossi vs Blu
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"
more_title: Anche questo fa parte del prodotto
more_stable_title: Diretta dallo smartphone
@@ -76,6 +73,19 @@ it:
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
cta_primary: Inizia gratis
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:
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."
@@ -90,6 +100,7 @@ it:
table_youtube: YouTube
table_replay: Archivio replay
table_download: Download telefono
table_cover_sponsor: Copertina personalizzata
table_price: Prezzo
table_price_free: "€0"
table_price_note: "listino %{list} — %{monthly}"
@@ -121,6 +132,7 @@ it:
youtube_mltv: Match Live TV
youtube_club: canale società
youtube_none: "no"
cover_sponsor_html: "Copertina personalizzabile <strong>con sponsor</strong>"
complete_billing: Completa dati di fatturazione
start_free: Inizia gratis
register_with_price: "Registrati — %{price}"
@@ -150,6 +162,8 @@ it:
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_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_live: Guarda le dirette
volleyball:
+1
View File
@@ -121,6 +121,7 @@ Rails.application.routes.draw do
post :regia_link
end
end
resources :stream_concurrency_violations, only: %i[index]
resources :stream_nodes, only: %i[index create destroy] do
member do
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.
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
enable_extension "pgcrypto"
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
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|
t.uuid "stream_session_id", 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.string "regia_token_digest"
t.datetime "regia_token_expires_at"
t.boolean "audio_muted", default: false, null: false
t.uuid "stream_node_id"
t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false
t.string "client_os"
t.string "app_version"
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 ["status"], name: "index_stream_sessions_on_status"
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"
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", "teams"
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_sessions", "matches"
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"
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.primary_color = "#e53935"
c.secondary_color = "#ffffff"
@@ -24,7 +24,7 @@ end
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"
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
match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m|
m.location = "PalaTigers - Milano"
match = team.matches.find_or_create_by!(opponent_name: MatchLiveTv::Demo::AWAY_TEAM) do |m|
m.location = MatchLiveTv::Demo.venue
m.scheduled_at = 2.hours.from_now
m.sets_to_win = 3
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"
end
+6 -6
View File
@@ -12,11 +12,11 @@ coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
end
clubs_data = [
{ name: "Tigers Volley", 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: "Volley Stars Roma", 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: "Padova Beach", sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 }
{ name: MatchLiveTv::Demo::HOME_TEAM, sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 },
{ name: "Team MLTV Nord", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 },
{ name: "Team MLTV Sud", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 },
{ name: "Team MLTV Basket", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 },
{ name: MatchLiveTv::Demo::AWAY_TEAM, sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 }
]
sport_keys = {
@@ -81,7 +81,7 @@ clubs.each_with_index do |row, index|
started = day.change(hour: 10 + (n % 6), min: 0)
ended = started + secs.seconds
match = team.matches.create!(
opponent_name: "Avversario demo #{n + 1}",
opponent_name: MatchLiveTv::Demo::AWAY_TEAM,
sport_key: team.sport_key,
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--ended { background: #37474f; color: #eceff1; }
.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 {
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 {
position: fixed;
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("Accesso con link")
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("Confronta i piani")
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.store_app_store_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.play_store_url).size).to eq(3)
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("Niente password condivise")
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("30 giorni soddisfatti o rimborsati")
expect(response.body).to include("ti rimborsiamo il 100%")
+11 -11
View File
@@ -1,12 +1,12 @@
require "rails_helper"
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
club.teams.create!(
name: "Tigers Volley U17",
name: "Team MLTV U15",
sport_key: "pallavolo",
description: "Squadra giovanile under 17.",
description: "Squadra giovanile under 15.",
roster_public: true
)
end
@@ -15,9 +15,9 @@ RSpec.describe "Public team page", type: :request do
get public_team_page_path(team.slug)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Tigers Volley U17")
expect(response.body).to include("ASD Eagles")
expect(response.body).to include("Squadra giovanile under 17")
expect(response.body).to include("Team MLTV U15")
expect(response.body).to include("Team MLTV")
expect(response.body).to include("Squadra giovanile under 15")
end
it "restituisce 404 per slug inesistente" do
@@ -41,13 +41,13 @@ RSpec.describe "Public team page", type: :request do
end
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!(: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
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!(
match: match, user: user, status: "live", platform: "matchlivetv",
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
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")
end
it "filtra per sport via query string" do
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
@@ -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") }
it "genera uno slug dal nome" do
team = club.teams.build(name: "Crazy Volley U17B", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("crazy-volley-u17b")
team = club.teams.build(name: "Team MLTV U15", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("team-mltv-u15")
end
it "aggiunge un suffisso se lo slug esiste già" do
club.teams.create!(name: "Tigers", sport_key: "pallavolo")
team = club.teams.build(name: "Tigers", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("tigers-2")
club.teams.create!(name: "Team MLTV", sport_key: "pallavolo")
team = club.teams.build(name: "Team MLTV", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("team-mltv-2")
end
end
@@ -14,6 +14,8 @@ import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.coroutines.delay
import retrofit2.HttpException
class SessionApiException(message: String, cause: Throwable) : Exception(message, cause)
class SessionRepository(
private val api: MatchLiveApi,
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 =
api.session(sessionId).toDomain()
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 =
api.stopSession(sessionId).toDomain()
@@ -73,7 +73,11 @@ class SessionRepository(
api.pauseSession(sessionId).toDomain()
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 =
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(
id: "match-1",
teamId: "team-1",
teamName: "Tigers Volley",
opponentName: "Avversario",
teamName: "Team MLTV",
opponentName: "Team Guest",
location: nil,
scheduledAt: nil,
sportKey: "pallavolo",