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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
@@ -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,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,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") %>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 qu’un 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 qu’un é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é."
|
||||
|
||||
@@ -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 un’altra 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 un’altra 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 un’altra diretta mentre questa era già in corso (o è stato bloccato da un’altra 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."
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
fr:
|
||||
api:
|
||||
errors:
|
||||
user_concurrent_stream: "Un direct est déjà en cours avec ce compte. Arrêtez-le avant d’en 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"
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
it:
|
||||
api:
|
||||
errors:
|
||||
user_concurrent_stream: "Hai già una diretta in corso con questo account. Chiudila prima di avviarne un’altra."
|
||||
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"
|
||||
|
||||
@@ -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"
|
||||
@@ -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:
|
||||
|
||||
@@ -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 club’s 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 club’s 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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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: L’identité 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 s’affiche 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:
|
||||
|
||||
@@ -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 dell’inizio 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 dell’inizio della diretta, permettendo di dare visibilità alle realtà che sostengono il club.
|
||||
cta_signup: Registra la squadra
|
||||
cta_live: Guarda le dirette
|
||||
volleyball:
|
||||
|
||||
@@ -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
|
||||
Generated
+36
-2
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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%")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user