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>
|
||||
Reference in New Issue
Block a user