Compare commits

...
9 Commits
Author SHA1 Message Date
eminuxandCursor d52434fb2e 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>
2026-09-01 12:52:26 +02:00
eminuxandCursor 356aa28fad Aggiunge la garanzia Soddisfatti o rimborsati di 30 giorni su prezzi, homepage e Termini.
Riduce il rischio percepito all'acquisto (Light e Full) e chiama l'archivio replay in modo orientato all'utente, senza cambiare prezzi o logica dei piani.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 19:27:28 +02:00
Emiliano FrascaroandCursor 1185d0ce61 Prepara iOS 2.0.12 build 35 per TestFlight e rafforza i test in produzione.
Forza dirette unlisted sul sito (non YouTube), copertina di test e verifica che in pausa gli spettatori vedano la slate. Alza il numero di build oltre le 33–34 già su App Store Connect.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 90d481a2d1 Rende più robusti i UI test iOS (logout, copertina, YouTube non selezionato in prod).
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
Emiliano FrascaroandCursor 50b8a8c8e2 Allinea iOS ad Android sul dialog Configura/Più tardi e porta l'app a 2.0.12.
Aggiunge UI test sul simulatore (hub, lingue, wizard, diretta) e identificatori di accessibilità per poterli ripetere su collaudo e produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 17:54:33 +02:00
eminuxandCursor 79850dfc2c Non far crashare il reset password se SMTP rifiuta il destinatario.
Un account di test su dominio .test faceva 500; ora l'invio fallito viene loggato e l'API resta ok.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 12:00:22 +02:00
eminuxandCursor 645807b853 Evita 500 senza SMTP e chiude gli incidenti overflow stale.
Reset password e mail replay usano deliver_mail; il health check overflow risolve tutte le fingerprint del kind, non solo quella sana.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-31 08:55:36 +02:00
eminuxandCursor b08049cf69 Fix home 500: usa request.cookie_jar nella partial analytics suppress.
L'action cookies di PagesController mascherava il helper cookies nelle viste marketing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:31:05 +02:00
eminuxandCursor 29c8afb94d Admin analytics: heatmap per device, anteprima staff e analisi costi.
Separa heatmap mobile/desktop, opt-out analytics per operatori, dashboard costi con KPI e trend mensili.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-28 19:25:38 +02:00
133 changed files with 4685 additions and 212 deletions
@@ -8,6 +8,7 @@ module Admin
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence
}
@analytics_preview_active = analytics_preview_active?
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
@@ -41,15 +42,23 @@ module Admin
@page_path = params[:page_path].to_s
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
from = parse_date(params[:from]) || 7.days.ago.to_date
to = parse_date(params[:to]) || Time.zone.today
@device_tab_stats = device_tab_stats(@page_path, from, to)
device = resolve_heatmap_device(@device_tab_stats, params[:device].presence)
@filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence,
from: from,
to: to,
device: device,
layer: params[:layer].to_s
}
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
cells = AnalyticsPageCell.where(
page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@click_total = cells.sum(:click_count)
@move_total = cells.sum(:move_count)
@@ -67,8 +76,11 @@ module Admin
@max_weight = @cells.values.max.to_i
@total_points = @cells.values.sum
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
stats = AnalyticsPageStat.where(
page_path: @page_path,
day: @filters[:from]..@filters[:to],
device: @filters[:device]
)
@pageviews = stats.sum(:pageview_count)
@scroll_samples = stats.sum(:scroll_samples)
@scroll_sum = stats.sum(:scroll_sum_pct)
@@ -78,8 +90,30 @@ module Admin
@snapshot = find_snapshot(@page_path, @filters[:device])
end
def preview_enable
Analytics::Suppress.enable!(cookies)
redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled")
end
def preview_disable
Analytics::Suppress.disable!(cookies)
redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled")
end
private
def analytics_preview_active?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def preview_return_to(value)
path = value.to_s.strip
return root_path if path.blank?
return path if path.start_with?("/") && !path.start_with?("//")
admin_analytics_path
end
def parse_date(value)
return nil if value.blank?
@@ -88,14 +122,43 @@ module Admin
nil
end
def find_snapshot(page_path, device)
scope = AnalyticsPageSnapshot.where(page_path: page_path)
if device.present? && AnalyticsEvent::DEVICES.include?(device)
snap = scope.find_by(device: device)
return snap if snap&.image&.attached?
def device_tab_stats(page_path, from, to)
cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
.group(:device)
.pluck(
:device,
Arel.sql("SUM(click_count)"),
Arel.sql("SUM(move_count)")
)
cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] }
pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to)
.group(:device)
.sum(:pageview_count)
AnalyticsEvent::DEVICES.index_with do |device|
cells = cell_by_device[device] || { clicks: 0, moves: 0 }
cells.merge(pageviews: pageview_totals[device].to_i)
end
end
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
def resolve_heatmap_device(tab_stats, requested)
if requested.present? && AnalyticsEvent::DEVICES.include?(requested)
return requested
end
AnalyticsEvent::DEVICES.max_by do |device|
stats = tab_stats[device]
stats[:clicks] + stats[:moves] + stats[:pageviews]
end
end
def find_snapshot(page_path, device)
return nil unless AnalyticsEvent::DEVICES.include?(device)
AnalyticsPageSnapshot.where(page_path: page_path, device: device)
.order(captured_at: :desc)
.detect { |snapshot| snapshot.image.attached? }
end
end
end
@@ -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
@@ -0,0 +1,68 @@
# frozen_string_literal: true
module Admin
class CostEntriesController < Admin::BaseController
before_action :set_entry, only: %i[edit update destroy]
def new
@entry = PlatformCostEntry.new(
month: parse_month(params[:month]) || Time.zone.today.beginning_of_month,
label: PlatformCostEntry::DEFAULT_LABEL
)
end
def create
@entry = PlatformCostEntry.new(entry_attributes)
if @entry.save
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @entry.update(entry_attributes)
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated")
else
flash.now[:alert] = @entry.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
month = @entry.month
@entry.destroy!
redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed")
end
private
def set_entry
@entry = PlatformCostEntry.find(params[:id])
end
def entry_attributes
attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes)
if attrs[:amount_euros].present?
attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros))
end
attrs[:notes] = nil if attrs[:notes].blank?
attrs
end
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_param(date)
date.strftime("%Y-%m")
end
end
end
@@ -0,0 +1,30 @@
# frozen_string_literal: true
module Admin
class CostsController < Admin::BaseController
def index
@month = parse_month(params[:month]) || Time.zone.today.beginning_of_month
analytics = Admin::CostAnalytics.new(month: @month)
@summary = analytics.summary
@trend = analytics.trend
@clubs = analytics.club_breakdown
@entries = PlatformCostEntry.for_month(@month).ordered
@month_options = month_options(@month)
end
private
def parse_month(value)
return nil if value.blank?
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
rescue ArgumentError, TypeError
nil
end
def month_options(selected)
start = selected - 23.months
(0..23).map { |i| start + i.months }.reverse
end
end
end
@@ -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
@@ -2,7 +2,13 @@
module Analytics
class EventsController < ActionController::API
include ActionController::Cookies
def create
if analytics_suppressed?
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
end
payload = parse_payload
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
@@ -15,6 +21,10 @@ module Analytics
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
def parse_payload
body = request.request_parameters
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
@@ -2,7 +2,13 @@
module Analytics
class SnapshotsController < ActionController::API
include ActionController::Cookies
def create
if analytics_suppressed?
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
end
result = Analytics::SnapshotIngest.new(
path: params[:path] || params[:page_path],
device: params[:device],
@@ -17,5 +23,11 @@ module Analytics
render json: { ok: true, skipped: result.skipped }, status: :accepted
end
private
def analytics_suppressed?
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
end
end
end
+21
View File
@@ -1,4 +1,21 @@
module AdminHelper
def format_euros(cents, precision: 2)
return I18n.t("admin.common.dash") if cents.nil?
format("%.*f €", precision, cents.to_f / 100.0)
end
def format_hours(hours)
return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0
total_minutes = (hours.to_f * 60).round
format_duration_minutes(total_minutes)
end
def admin_month_label(month)
I18n.l(month, format: "%B %Y")
end
def format_bytes(bytes)
return "" if bytes.nil?
@@ -171,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
+4
View File
@@ -3,6 +3,10 @@ module LegalHelper
"20 agosto 2026"
end
def terms_last_updated
"31 agosto 2026"
end
def cookie_policy_last_updated
"3 giugno 2026"
end
+51
View File
@@ -0,0 +1,51 @@
# frozen_string_literal: true
class PlatformCostEntry < ApplicationRecord
DEFAULT_LABEL = "Piattaforma produzione"
validates :month, presence: true
validates :label, presence: true, length: { maximum: 120 }
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
before_validation :normalize_month
scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) }
scope :ordered, -> { order(month: :desc, created_at: :desc) }
def month=(value)
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
super(Date.strptime(value, "%Y-%m"))
else
super(value)
end
end
def amount_euros
amount_cents.to_f / 100.0
end
private
def normalize_month
return if month.blank?
parsed =
case month
when Date
month
when Time, ActiveSupport::TimeWithZone
month.to_date
when String
if month.match?(/\A\d{4}-\d{2}\z/)
Date.strptime(month, "%Y-%m")
else
Date.parse(month)
end
else
Date.parse(month.to_s)
end
self.month = parsed.beginning_of_month
rescue ArgumentError, TypeError
errors.add(:month, :invalid)
end
end
@@ -0,0 +1,71 @@
# frozen_string_literal: true
class StreamConcurrencyViolation < ApplicationRecord
LOOKBACK = 30.days
belongs_to :user, optional: true
belongs_to :occupying_session, class_name: "StreamSession", optional: true
belongs_to :attempted_session, class_name: "StreamSession", optional: true
belongs_to :occupying_club, class_name: "Club", optional: true
belongs_to :attempted_club, class_name: "Club", optional: true
validates :user_email, :occupying_match_label, :attempted_match_label, presence: true
validates :attempt_action, inclusion: { in: %w[start resume] }
scope :recent, -> { order(created_at: :desc) }
scope :since, ->(time) { where("created_at >= ?", time) }
scope :lookback, -> { since(LOOKBACK.ago) }
scope :two_devices, -> { where(devices_differ: true) }
scope :for_club, lambda { |club_id|
where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id)
}
scope :for_session, lambda { |session_id|
where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id)
}
def self.record!(attempted:, occupying:, action: "start")
user = attempted.user || occupying.user
occupying_club = occupying.match&.team&.club
attempted_club = attempted.match&.team&.club
occupying_device = occupying.client_device_label
attempted_device = attempted.client_device_label
create!(
user: user,
occupying_session: occupying,
attempted_session: attempted,
occupying_club: occupying_club,
attempted_club: attempted_club,
attempt_action: action.to_s,
user_email: user&.email.presence || "unknown",
user_name: user&.name,
occupying_club_name: occupying_club&.name,
attempted_club_name: attempted_club&.name,
occupying_match_label: occupying.match_label,
attempted_match_label: attempted.match_label,
occupying_status: occupying.status,
occupying_device: occupying_device,
attempted_device: attempted_device,
devices_differ: StreamSession.devices_differ?(occupying, attempted),
metadata: {
occupying_session_id: occupying.id,
attempted_session_id: attempted.id,
occupying_client_os: occupying.client_os,
attempted_client_os: attempted.client_os,
occupying_app_version: occupying.app_version,
attempted_app_version: attempted.app_version
}.compact
)
rescue StandardError => e
Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}")
nil
end
def club_name
attempted_club_name.presence || occupying_club_name
end
def operator_label
[user_name.presence, user_email].compact.join(" · ")
end
end
+33
View File
@@ -10,6 +10,10 @@ class StreamSession < ApplicationRecord
belongs_to :user
belongs_to :stream_node, optional: true
has_many :stream_events, dependent: :destroy
has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
has_one :score_state, dependent: :destroy
has_one :recording
has_many :device_states, dependent: :destroy
@@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord
stream_node&.role.presence || ingest_role
end
def match_label
team_name = match&.team&.name
opponent = match&.opponent_name
return id.to_s if team_name.blank?
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
end
def client_device_label
parts = []
parts << client_os if client_os.present?
device = [device_manufacturer, device_model].compact_blank.join(" ")
parts << device if device.present?
parts << "OS #{os_version}" if os_version.present?
parts.join(" · ").presence
end
def client_device_key
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
end
def self.devices_differ?(left, right)
ka = left.client_device_key
kb = right.client_device_key
return false if ka.delete("|").blank? || kb.delete("|").blank?
ka != kb
end
def rtmp_ingest_url
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
+1
View File
@@ -11,6 +11,7 @@ class User < ApplicationRecord
has_many :clubs, through: :club_memberships
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
has_many :stream_sessions, dependent: :nullify
has_many :stream_concurrency_violations, dependent: :nullify
def manageable_teams
staff_ids = teams.select(:id)
@@ -0,0 +1,150 @@
# frozen_string_literal: true
module Admin
class CostAnalytics
TREND_MONTHS = 12
def initialize(month:)
@month = month.to_date.beginning_of_month
end
def summary
build_period(@month)
end
def trend(months: TREND_MONTHS)
start_month = @month - (months - 1).months
months_list = (0...months).map { |i| start_month + i.months }
months_list.map { |m| build_period(m) }
end
def club_breakdown
range = month_range(@month)
rows = session_rows(range)
total_secs = rows.sum { |row| row.total_secs.to_i }
total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents)
revenue_by_club = revenue_by_club(range)
storage_by_club = storage_by_club_index
club_ids = rows.map(&:club_id)
clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id)
rows.map do |row|
club = clubs[row.club_id]
secs = row.total_secs.to_i
hours = secs / 3600.0
sessions_count = row.sessions_count.to_i
share = total_secs.positive? ? secs.to_f / total_secs : 0.0
allocated_cents = (total_cost_cents * share).round
revenue_cents = revenue_by_club[row.club_id].to_i
storage_bytes = storage_by_club[row.club_id].to_i
{
club_id: row.club_id,
club_name: row.club_name,
plan_slug: club&.subscription&.plan&.slug,
sessions: sessions_count,
hours: hours.round(2),
hours_share_pct: (share * 100).round(1),
allocated_cost_cents: allocated_cents,
revenue_cents: revenue_cents,
margin_cents: revenue_cents - allocated_cents,
cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil,
cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil,
storage_bytes: storage_bytes
}
end.sort_by { |row| [-row[:hours], row[:club_name]] }
end
private
def build_period(month)
range = month_range(month)
cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents)
sessions_scope = ended_sessions.where(ended_at: range)
total_secs = sessions_scope.sum(:total_duration_secs).to_i
hours = total_secs / 3600.0
sessions = sessions_scope.count
clubs_active = distinct_active_clubs(range)
revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents)
storage_bytes = Recording.ready.sum(:byte_size).to_i
storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0
kpis = compute_kpis(
cost_cents: cost_cents,
hours: hours,
sessions: sessions,
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_gb: storage_gb
)
{
month: month,
cost_cents: cost_cents,
sessions: sessions,
hours: hours.round(2),
clubs_active: clubs_active,
revenue_cents: revenue_cents,
storage_bytes: storage_bytes,
**kpis
}
end
def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:)
margin_cents = revenue_cents.to_i - cost_cents.to_i
margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil
hours_f = hours.to_f
sessions_i = sessions.to_i
clubs_i = clubs_active.to_i
storage_f = storage_gb.to_f
cost_i = cost_cents.to_i
{
cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil,
cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil,
cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil,
revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil,
cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil,
margin_cents: margin_cents,
margin_pct: margin_pct
}
end
def month_range(month)
month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day
end
def ended_sessions
StreamSession.where(status: "ended")
end
def distinct_active_clubs(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.distinct
.count("clubs.id")
end
def session_rows(range)
ended_sessions
.where(ended_at: range)
.joins(match: { team: :club })
.group("clubs.id", "clubs.name")
.select(
"clubs.id AS club_id",
"clubs.name AS club_name",
"COUNT(stream_sessions.id) AS sessions_count",
"SUM(stream_sessions.total_duration_secs) AS total_secs"
)
end
def revenue_by_club(range)
Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents)
end
def storage_by_club_index
Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size)
end
end
end
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module Analytics
module Suppress
COOKIE_NAME = "mltv_analytics_suppress"
COOKIE_MAX_AGE = 7 * 24 * 60 * 60
module_function
def active?(cookie_value)
cookie_value.to_s == "1"
end
def enable!(cookie_jar)
cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now)
end
def disable!(cookie_jar)
cookie_jar.delete(
COOKIE_NAME,
path: "/",
same_site: :lax,
secure: cookie_secure?
)
end
def cookie_options(value:, expires:)
{
value: value,
expires: expires,
path: "/",
httponly: true,
same_site: :lax,
secure: cookie_secure?
}
end
def cookie_secure?
Rails.application.config.force_ssl || Rails.env.production?
end
end
end
@@ -54,6 +54,8 @@ module Ops
def process_finding(finding)
if finding.healthy
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
# overflow usa fingerprint diverse (at_max / orphan_idle / budget) rispetto al check sano
Ops::IncidentRecorder.resolve_kind(finding.kind) if finding.kind == "stream_overflow"
else
Ops::IncidentRecorder.record(
kind: finding.kind,
@@ -10,6 +10,10 @@ module Ops
def resolve(fingerprint:)
new.resolve(fingerprint: fingerprint)
end
def resolve_kind(kind)
new.resolve_kind(kind)
end
end
def record(finding)
@@ -49,6 +53,10 @@ module Ops
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
end
def resolve_kind(kind)
Ops::Incident.open.where(kind: kind).find_each(&:resolve!)
end
private
def fingerprint_for(finding)
@@ -23,7 +23,7 @@ module Recordings
private
def deliver_expiring_soon(user)
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user))
rescue EOFError => e
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
end
@@ -19,7 +19,7 @@ module Recordings
private
def deliver_replay_ready(user)
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user))
rescue EOFError => e
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
@@ -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
+2
View File
@@ -10,8 +10,10 @@ module Sessions
end
cancel_timeout_job
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" })
+2
View File
@@ -5,9 +5,11 @@ module Sessions
end
def call
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
@@ -13,7 +13,7 @@ module Users
return if user.nil?
token = user.generate_password_reset!
UserMailer.password_reset(user, token).deliver_now
MatchLiveTv.deliver_mail(UserMailer.password_reset(user, token))
end
end
end
@@ -5,6 +5,32 @@
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
</div>
<section class="panel admin-analytics-preview">
<h3><%= t("admin.analytics.preview.title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
<% if @analytics_preview_active %>
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.disable"),
admin_analytics_preview_path,
method: :delete,
class: "admin-btn admin-btn--outline admin-btn--sm" %>
<%= link_to t("admin.analytics.preview.open_site"),
root_path,
class: "admin-btn admin-btn--primary admin-btn--sm",
target: "_blank",
rel: "noopener" %>
</div>
<% else %>
<div class="admin-filter-actions">
<%= button_to t("admin.analytics.preview.enable"),
admin_analytics_preview_path(return_to: root_path),
method: :post,
class: "admin-btn admin-btn--primary admin-btn--sm" %>
</div>
<% end %>
</section>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
@@ -58,8 +84,9 @@
<td class="muted"><%= avg %>%</td>
<td class="muted"><%= row[:max_scroll] %>%</td>
<td>
<%= link_to t("admin.analytics.index.heatmap"),
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
</td>
</tr>
<% end %>
+42 -14
View File
@@ -3,7 +3,7 @@
<div class="admin-page-head admin-session-head">
<div>
<p class="muted admin-page-sub">
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
</p>
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
<p><code class="admin-mono"><%= @page_path %></code></p>
@@ -11,8 +11,32 @@
</div>
<div class="panel admin-sessions-filters">
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
</p>
<div class="admin-locale-tabs" role="tablist">
<% AnalyticsEvent::DEVICES.each do |device| %>
<% stats = @device_tab_stats[device] %>
<% active = @filters[:device] == device %>
<%= link_to admin_analytics_page_path(
page_path: @page_path,
from: @filters[:from],
to: @filters[:to],
device: device,
layer: @filters[:layer]
),
class: "admin-locale-tab #{'is-active' if active}",
role: "tab",
"aria-selected": active do %>
<%= t("admin.analytics.devices.#{device}") %>
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
<% end %>
<% end %>
</div>
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
<%= hidden_field_tag :page_path, @page_path %>
<%= hidden_field_tag :device, @filters[:device] %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.from") %></span>
@@ -22,14 +46,6 @@
<span><%= t("admin.analytics.filters.to") %></span>
<%= date_field_tag :to, @filters[:to] %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.device") %></span>
<%= select_tag :device,
options_for_select(
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
@filters[:device]
) %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.layer") %></span>
<%= select_tag :layer,
@@ -91,7 +107,12 @@
<h3>
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
</h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
<p class="muted admin-table-sub">
<%= t(
"admin.analytics.show.heatmap_hint",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<% if @cells.any? %>
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
@@ -115,13 +136,20 @@
></canvas>
</div>
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
<%= t("admin.analytics.show.snapshot_meta",
device: @snapshot.device,
at: l(@snapshot.captured_at, format: :short)) %>
<%= t(
"admin.analytics.show.snapshot_meta",
device: t("admin.analytics.devices.#{@snapshot.device}"),
at: l(@snapshot.captured_at, format: :short)
) %>
</p>
<% else %>
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
<p class="muted admin-heatmap-fallback-note">
<%= t(
"admin.analytics.show.snapshot_missing",
device: t("admin.analytics.devices.#{@filters[:device]}")
) %>
</p>
<canvas
id="admin-heatmap"
class="admin-heatmap-overlay"
@@ -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 %>
@@ -0,0 +1,34 @@
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.month") %></span>
<%= text_field_tag "platform_cost_entry[month]",
@entry.month&.strftime("%Y-%m"),
type: "month",
required: true %>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.label") %></span>
<%= f.text_field :label, required: true, maxlength: 120 %>
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
</label>
<label class="admin-filter-field">
<span><%= t("admin.costs.entries.amount") %></span>
<%= number_field_tag "platform_cost_entry[amount_euros]",
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
step: 0.01,
min: 0.01,
required: true %>
</label>
<label class="admin-filter-field" style="grid-column: span 2;">
<span><%= t("admin.costs.entries.notes") %></span>
<%= f.text_area :notes, rows: 3 %>
</label>
</div>
<div class="admin-filter-actions">
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.entries.cancel"),
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
@@ -0,0 +1,10 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
</section>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
</div>
<section class="panel">
<%= render "form", url: admin_cost_entries_path, method: :post %>
</section>
@@ -0,0 +1,197 @@
<% content_for :body_class, "admin-body" %>
<% content_for :head do %>
<script>
window.adminCostTrend = <%= raw @trend.to_json %>;
window.adminCostI18n = {
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
hours: <%= raw t("admin.costs.charts.hours").to_json %>
};
</script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-costs.js?v=1" defer></script>
<% end %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
</div>
<div class="panel admin-sessions-filters">
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
<div class="admin-filter-grid">
<label class="admin-filter-field">
<span><%= t("admin.costs.index.month") %></span>
<%= select_tag :month,
options_for_select(
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
@month.strftime("%Y-%m")
) %>
</label>
</div>
<div class="admin-filter-actions">
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
<%= link_to t("admin.costs.index.add_entry"),
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
class: "admin-btn admin-btn--outline admin-btn--sm" %>
</div>
<% end %>
</div>
<section class="kpi-grid">
<div class="kpi-card kpi-card--accent">
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
<% if @summary[:margin_pct] %>
· <%= @summary[:margin_pct] %>%
<% end %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
</div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
</div>
<div class="kpi-card">
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
<div class="kpi-sub">
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
· <%= t("admin.costs.kpi.storage_note") %>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.trends_title") %></h3>
<div class="charts-grid">
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
</div>
<div class="chart-card">
<h3><%= t("admin.costs.charts.hours") %></h3>
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
</div>
</div>
</section>
<section class="panel" style="margin-bottom:1.25rem">
<h3><%= t("admin.costs.index.entries_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
<% if @entries.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.label") %></th>
<th><%= t("admin.costs.table.amount") %></th>
<th><%= t("admin.costs.table.notes") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @entries.each do |entry| %>
<tr>
<td><strong><%= entry.label %></strong></td>
<td><%= format_euros(entry.amount_cents) %></td>
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
<td class="admin-actions">
<%= link_to t("admin.announcements.actions.edit"),
edit_admin_cost_entry_path(entry),
class: "admin-btn admin-btn--sm" %>
<%= button_to t("admin.costs.entries.delete"),
admin_cost_entry_path(entry),
method: :delete,
class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
<tfoot>
<tr>
<th><%= t("admin.costs.kpi.platform_cost") %></th>
<th><%= format_euros(@summary[:cost_cents]) %></th>
<th colspan="2"></th>
</tr>
</tfoot>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.costs.index.clubs_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
<% if @clubs.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.costs.table.club") %></th>
<th><%= t("admin.costs.table.plan") %></th>
<th><%= t("admin.costs.table.hours") %></th>
<th><%= t("admin.costs.table.hours_share") %></th>
<th><%= t("admin.costs.table.allocated_cost") %></th>
<th><%= t("admin.costs.table.revenue") %></th>
<th><%= t("admin.costs.table.margin") %></th>
<th><%= t("admin.costs.table.cost_per_hour") %></th>
<th><%= t("admin.costs.table.cost_per_session") %></th>
<th><%= t("admin.costs.table.storage") %></th>
</tr>
</thead>
<tbody>
<% @clubs.each do |row| %>
<tr>
<td>
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
</td>
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
<td><%= format_hours(row[:hours]) %></td>
<td class="muted"><%= row[:hours_share_pct] %>%</td>
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
<td><%= format_euros(row[:revenue_cents]) %></td>
<td><%= format_euros(row[:margin_cents]) %></td>
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
<% end %>
</section>
@@ -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>
+7 -1
View File
@@ -5,7 +5,8 @@
<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">
<% end %>
@@ -31,7 +32,12 @@
<%= 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") %>
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
+4 -3
View File
@@ -7,9 +7,10 @@
<meta name="application-name" content="Match Live TV">
<%= csrf_meta_tags %>
<%= render "shared/meta_tags" %>
<%= 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=76">
<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" %>
@@ -26,7 +27,7 @@
<script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script>
<script src="/cookie-consent.js?v=2" defer></script>
<script src="/site-analytics.js?v=6" defer></script>
<script src="/cookie-consent.js?v=3" defer></script>
</body>
</html>
@@ -6,8 +6,9 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= csrf_meta_tags %>
<%= 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=76">
<link rel="stylesheet" href="/marketing.css?v=81">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
@@ -21,7 +22,7 @@
<%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=7" defer></script>
<script src="/site-analytics.js?v=4" defer></script>
<script src="/cookie-consent.js?v=2" defer></script>
<script src="/site-analytics.js?v=6" defer></script>
<script src="/cookie-consent.js?v=3" defer></script>
</body>
</html>
@@ -21,6 +21,9 @@
<%= hidden_field_tag :plan, plan_return if plan_return %>
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
<%= render "shared/billing_profile_fields", record: @club %>
<% if plan_return %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
<% end %>
</div>
@@ -21,6 +21,9 @@
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
<% elsif MatchLiveTv.stripe_enabled? %>
<%= render "shared/stripe_secure_payment" %>
<% if @subscription.blank? || @subscription.plan&.slug == "free" || !@subscription.active? %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<% end %>
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
@@ -26,6 +26,9 @@
[t("club.new.plan_full"), "premium_full"]
], params[:plan] || "free") %>
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
<% if params[:plan].presence_in(%w[premium_light premium_full]) %>
<%= render "shared/refund_guarantee", variant: "checkout" %>
<% end %>
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
<% end %>
</div>
+2 -2
View File
@@ -164,8 +164,8 @@
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
<p class="demo-label"><%= t("live.index.demo_label") %></p>
<article class="live-card live-card--demo">
<h3>Tigers Volley vs ASD Eagles</h3>
<p class="meta"><%= t("live.index.demo_meta") %></p>
<h3><%= MatchLiveTv::Demo.match_title %></h3>
<p class="meta"><%= MatchLiveTv::Demo.live_meta %></p>
<p class="card-score">
<span class="card-sets"><%= t("live.index.demo_sets") %></span>
<span class="card-points">18 - 16</span>
@@ -70,7 +70,31 @@
<%= t("pages.faq.q8_answer") %>
</p>
</details>
<details class="faq-item" id="faq-garanzia" data-mltv-event="guarantee_faq_open">
<summary><%= t("pages.faq.q9_question") %></summary>
<p>
<%= raw t(
"pages.faq.q9_answer_html",
terms_link: link_to(t("pages.faq.q9_terms_link"), public_termini_path(anchor: "garanzia-rimborso"))
) %>
</p>
</details>
<details class="faq-item">
<summary><%= t("pages.faq.q10_question") %></summary>
<p>
<%= t("pages.faq.q10_answer") %>
</p>
</details>
</div>
<script>
(function () {
if (location.hash !== "#faq-garanzia") return;
var item = document.getElementById("faq-garanzia");
if (item) item.open = true;
})();
</script>
<p style="text-align:center;margin:40px 0">
<%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %>
@@ -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>
@@ -101,6 +103,7 @@
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
</div>
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
<%= render "shared/refund_guarantee", variant: "cta" %>
</section>
<section class="section wrap seo-prose">
@@ -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>
+16 -1
View File
@@ -4,7 +4,7 @@
<div class="wrap legal-doc">
<h1><%= t("legal.terms.h1") %></h1>
<p class="legal-meta"><%= t("legal.terms.meta", date: legal_last_updated) %></p>
<p class="legal-meta"><%= t("legal.terms.meta", date: terms_last_updated) %></p>
<section>
<h2><%= t("legal.terms.s1_title") %></h2>
@@ -43,6 +43,21 @@
<p><%= t("legal.terms.s3_p3") %></p>
</section>
<section id="garanzia-rimborso">
<h2><%= t("legal.terms.s3b_title") %></h2>
<p><%= t("legal.terms.s3b_p1") %></p>
<p><%= t("legal.terms.s3b_p2") %></p>
<p>
<%= raw t(
"legal.terms.s3b_p3_html",
email_link: link_to(MatchLiveTv.support_email, "mailto:#{MatchLiveTv.support_email}")
) %>
</p>
<p><%= t("legal.terms.s3b_p4") %></p>
<p><%= t("legal.terms.s3b_p5") %></p>
<p><%= t("legal.terms.s3b_p6") %></p>
</section>
<section>
<h2><%= t("legal.terms.s4_title") %></h2>
<p><strong><%= t("legal.terms.s4_lead1") %></strong></p>
@@ -0,0 +1,7 @@
<% if Analytics::Suppress.active?(request.cookie_jar[Analytics::Suppress::COOKIE_NAME]) %>
<meta name="mltv-analytics-suppress" content="1">
<div class="mltv-preview-badge" role="status">
<span><%= t("ui.analytics_preview.badge") %></span>
<%= link_to t("ui.analytics_preview.manage"), admin_analytics_path, class: "mltv-preview-badge__link" %>
</div>
<% end %>
+15 -1
View File
@@ -1,4 +1,4 @@
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false) %>
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false, show_guarantee_banner: nil) %>
<% club ||= @club %>
<% entitlements ||= @entitlements %>
<% subscription ||= @subscription %>
@@ -65,10 +65,16 @@
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>
<% end %>
<% first_purchase = !billing_mode || action&.fetch(:kind, nil).in?(%i[checkout_options bank_only quoted]) %>
<% show_cta_guarantee = plan.slug.in?(%w[premium_light premium_full]) && first_purchase %>
<div class="plan-card__cta">
<% if billing_mode %>
<% action_kind = action[:kind] %>
<% quote = club&.active_billing_quote %>
@@ -131,6 +137,14 @@
<% end %>
</div>
<% end %>
<% if show_cta_guarantee %>
<%= render "shared/refund_guarantee", variant: "compact" %>
<% end %>
</div>
</div>
<% end %>
</div>
<% if local_assigns.fetch(:show_guarantee_banner) { !billing_mode || current_slug.to_s == "free" } %>
<%= render "shared/refund_guarantee", variant: "pricing" %>
<% end %>
@@ -0,0 +1,35 @@
<%# locals: (variant: "pricing") %>
<% variant = local_assigns.fetch(:variant, "pricing").to_s %>
<% if variant == "pricing" %>
<aside class="refund-guarantee refund-guarantee--pricing" aria-label="<%= t("guarantee.aria_pricing") %>">
<span class="refund-guarantee__icon" aria-hidden="true">
<i class="fa-solid fa-shield-halved"></i>
</span>
<div class="refund-guarantee__copy">
<p class="refund-guarantee__title">
<%= t("guarantee.title") %><span class="refund-guarantee__kicker"> · <%= t("guarantee.kicker_days") %></span>
</p>
<p class="refund-guarantee__subtitle"><%= t("guarantee.subtitle") %></p>
<p class="refund-guarantee__body"><%= t("guarantee.body") %></p>
<p class="refund-guarantee__more">
<%= link_to t("guarantee.learn_more"), public_faq_path(anchor: "faq-garanzia"), data: { mltv_event_click: "guarantee_learn_more" } %>
</p>
</div>
</aside>
<% elsif variant == "compact" %>
<p class="refund-guarantee refund-guarantee--compact">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<span><%= t("guarantee.compact") %></span>
</p>
<% elsif variant == "cta" %>
<p class="refund-guarantee refund-guarantee--cta">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<span><%= t("guarantee.home_note") %></span>
</p>
<% elsif variant == "checkout" %>
<div class="refund-guarantee refund-guarantee--checkout">
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
<p><%= t("guarantee.checkout") %></p>
</div>
<% end %>
@@ -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>
@@ -1,3 +1,6 @@
require "net/smtp"
require "openssl"
module MatchLiveTv
class << self
def jwt_secret
@@ -102,6 +105,7 @@ module MatchLiveTv
end
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
# Errori SMTP (dominio invalido, EOF Aruba, porta chiusa) non devono far crashare la request.
def deliver_mail(mail)
if Rails.env.production? && !smtp_configured?
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
@@ -110,6 +114,9 @@ module MatchLiveTv
mail.deliver_now
true
rescue EOFError, Net::SMTPError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError => e
Rails.logger.warn("[mail] delivery failed: #{mail.subject} #{e.class}: #{e.message}")
false
end
def privacy_controller_name
+63 -2
View File
@@ -10,7 +10,9 @@ de:
billing: Abrechnung
youtube: YouTube
sessions: Sitzungen
stream_concurrency: Konto-Missbrauch
analytics: Analytics
costs: Kosten
stream_nodes: Stream-Knoten
password: Passwort
logout: Abmelden
@@ -50,6 +52,9 @@ de:
announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert
announcement_destroyed: Hinweis gelöscht
cost_entry_created: Kostenposition gespeichert.
cost_entry_updated: Kostenposition aktualisiert.
cost_entry_destroyed: Kostenposition gelöscht.
common:
free_plan: Free
yes: "Ja"
@@ -99,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)"
@@ -189,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
@@ -220,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."
@@ -292,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."
@@ -371,6 +418,10 @@ de:
layer: Ebene
layer_move: Mausbewegungen
layer_click: Klicks
devices:
mobile: Mobil
tablet: Tablet
desktop: Desktop
index:
title: Website-Analytics
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
@@ -391,14 +442,24 @@ de:
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
heatmap_moves: Mausbewegungs-Karte
heatmap_clicks: Klickkarte
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
heatmap_hint: Farbiges Overlay auf dem %{device}-Screenshot. Grün = wenig, Rot = viel. Daten werden nicht zwischen Geräten gemischt.
device_tabs_label: Gerät
preview_title: Seiten-Screenshot
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen.
snapshot_missing: Noch kein Screenshot für %{device}. Seite auf diesem Gerät mit Analytics-Einwilligung besuchen und erneut versuchen.
snapshot_meta: "Screenshot %{device} · %{at}"
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
heatmap_title: Klickkarte
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
preview:
title: Vorschau ohne Tracking
lead: Öffentliche Website durchsuchen ohne Pageviews, Klicks, Bewegungen oder Heatmap-Screenshots zu speichern. Für Produktionstests.
active: Staff-Vorschau in diesem Browser aktiv (7 Tage oder bis Deaktivierung).
enable: Staff-Vorschau aktivieren
disable: Vorschau deaktivieren
open_site: Website öffnen
enabled: Staff-Vorschau aktiviert. Dieser Browser speichert keine Analytics.
disabled: Staff-Vorschau deaktiviert.
billing:
index:
+123 -2
View File
@@ -10,7 +10,9 @@ en:
billing: Billing
youtube: YouTube
sessions: Sessions
stream_concurrency: Account abuse
analytics: Analytics
costs: Costs
stream_nodes: Stream nodes
password: Password
logout: Log out
@@ -50,6 +52,9 @@ en:
announcement_created: Notice saved
announcement_updated: Notice updated
announcement_destroyed: Notice deleted
cost_entry_created: Cost entry saved.
cost_entry_updated: Cost entry updated.
cost_entry_destroyed: Cost entry deleted.
common:
free_plan: Free
yes: "Yes"
@@ -99,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)"
@@ -189,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
@@ -220,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."
@@ -292,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."
@@ -371,6 +418,10 @@ en:
layer: Layer
layer_move: Mouse moves
layer_click: Clicks
devices:
mobile: Mobile
tablet: Tablet
desktop: Desktop
index:
title: Site analytics
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
@@ -391,14 +442,84 @@ en:
scroll_hint: "Average %{avg}% · max %{max}%"
heatmap_moves: Mouse-move map
heatmap_clicks: Click map
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
heatmap_hint: Colored overlay on the %{device} screenshot. Green = low, red = high. Data is never mixed across devices.
device_tabs_label: Device
preview_title: Page screenshot
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds.
snapshot_missing: No screenshot for %{device} yet. Visit the page on that device with analytics consent, then retry in a few seconds.
snapshot_meta: "Screenshot %{device} · captured %{at}"
no_points: No aggregated points for this layer in the period.
heatmap_title: Click map
no_clicks: No aggregated clicks for this page in the period.
preview:
title: Preview without tracking
lead: Browse the public site without recording pageviews, clicks, moves or heatmap screenshots. Useful for production smoke tests.
active: Staff preview is active in this browser (7 days or until disabled).
enable: Enable staff preview
disable: Disable preview
open_site: Open site
enabled: Staff preview enabled. This browser will not record analytics.
disabled: Staff preview disabled.
costs:
index:
title: Cost analysis
lead: Enter monthly platform costs and compare cost, usage and revenue. KPIs recalculate automatically when you edit entries.
month: Month
apply: Apply
entries_title: Cost entries for the month
entries_lead: Add multiple line items (e.g. servers, storage, bandwidth). Month total is the sum of entries.
add_entry: Add entry
no_entries: No cost entries for this month. Add platform cost to see KPIs.
clubs_title: Allocated cost per club
clubs_lead: Infrastructure cost is split proportionally by streamed hours in the month.
no_clubs: No ended streams in this month.
trends_title: Trends (last 12 months)
kpi:
platform_cost: Platform cost
revenue: Revenue collected
margin: Margin
margin_pct: Margin %
hours: Streamed hours
sessions: Streams
clubs_active: Active clubs
storage: Replay storage
cost_per_hour: Cost / stream hour
cost_per_session: Cost / stream
cost_per_club: Cost / active club
revenue_per_hour: Revenue / stream hour
cost_per_gb: Cost / GB storage
storage_note: Current cumulative storage (not monthly).
charts:
cost_revenue: Cost vs revenue
cost_per_hour: Cost per streamed hour
hours: Streamed hours
table:
label: Item
amount: Amount
notes: Notes
club: Club
plan: Plan
hours: Hours
hours_share: Hour share
allocated_cost: Allocated cost
revenue: Revenue
margin: Margin
cost_per_hour: €/hour
cost_per_session: €/stream
storage: Storage
entries:
new_title: New cost entry
edit_title: Edit cost entry
month: Month
label: Description
label_hint: E.g. Hetzner, stream nodes, S3, egress.
amount: Amount (€)
notes: Notes
save: Save
cancel: Cancel
delete: Delete
delete_confirm: Delete this cost entry?
billing:
index:
+63 -2
View File
@@ -10,7 +10,9 @@ es:
billing: Facturación
youtube: YouTube
sessions: Sesiones
stream_concurrency: Abuso de cuenta
analytics: Analytics
costs: Costes
stream_nodes: Nodos stream
password: Contraseña
logout: Salir
@@ -50,6 +52,9 @@ es:
announcement_created: Aviso guardado
announcement_updated: Aviso actualizado
announcement_destroyed: Aviso eliminado
cost_entry_created: Partida de coste guardada.
cost_entry_updated: Partida de coste actualizada.
cost_entry_destroyed: Partida de coste eliminada.
common:
free_plan: Free
yes: "Sí"
@@ -99,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)"
@@ -189,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
@@ -220,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."
@@ -292,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."
@@ -371,6 +418,10 @@ es:
layer: Capa
layer_move: Movimientos del ratón
layer_click: Clics
devices:
mobile: Móvil
tablet: Tablet
desktop: Escritorio
index:
title: Analytics del sitio
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
@@ -391,14 +442,24 @@ es:
scroll_hint: "Media %{avg}% · máximo %{max}%"
heatmap_moves: Mapa de movimientos
heatmap_clicks: Mapa de clics
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
heatmap_hint: Superposición de color sobre la captura %{device}. Verde = poco, rojo = mucho. Los datos no se mezclan entre dispositivos.
device_tabs_label: Dispositivo
preview_title: Captura de página
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo.
snapshot_missing: Aún no hay captura para %{device}. Visita la página con ese dispositivo y consentimiento analytics, y vuelve a intentarlo.
snapshot_meta: "Captura %{device} · %{at}"
no_points: No hay puntos agregados para esta capa en el periodo.
heatmap_title: Mapa de clics
no_clicks: No hay clics agregados para esta página en el periodo.
preview:
title: Vista previa sin seguimiento
lead: Navega el sitio público sin registrar pageviews, clics, movimientos ni capturas heatmap. Útil para pruebas en producción.
active: Vista previa staff activa en este navegador (7 días o hasta desactivar).
enable: Activar vista previa staff
disable: Desactivar vista previa
open_site: Abrir sitio
enabled: Vista previa staff activada. Este navegador no registrará analytics.
disabled: Vista previa staff desactivada.
billing:
index:
+63 -2
View File
@@ -10,7 +10,9 @@ fr:
billing: Facturation
youtube: YouTube
sessions: Sessions
stream_concurrency: Abus de compte
analytics: Analytics
costs: Coûts
stream_nodes: Nœuds stream
password: Mot de passe
logout: Déconnexion
@@ -50,6 +52,9 @@ fr:
announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour
announcement_destroyed: Alerte supprimée
cost_entry_created: Poste de coût enregistré.
cost_entry_updated: Poste de coût mis à jour.
cost_entry_destroyed: Poste de coût supprimé.
common:
free_plan: Free
yes: "Oui"
@@ -99,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é)"
@@ -189,6 +200,36 @@ fr:
table:
resolved_at: Résolu
none: Aucun incident résolu récemment.
stream_concurrency:
index:
title: Abus de compte
lead: Tentatives de démarrer un second direct avec le même compte alors quun autre était déjà en cours (connecting, live, reconnecting ou en pause).
results: "%{shown} sur %{total} affichés"
none: Aucune tentative enregistrée.
kpi:
lookback: 30 derniers jours
lookback_sub: tentatives bloquées
two_devices: Deux appareils
two_devices_sub: modèles de téléphone différents
filters:
q: Rechercher
q_placeholder: E-mail, club, match…
two_devices: Filtre
two_devices_hint: Uniquement les tentatives depuis des appareils différents
apply: Filtrer
reset: Réinitialiser
table:
when: Quand
account: Compte
club: Club
occupying: Direct déjà en cours
attempted: Tentative bloquée
action:
start: démarrage
resume: reprise
badge:
two_devices: 2 téléphones
same_or_unknown: même appareil / n. d.
clubs:
index:
title: Clubs et équipes
@@ -220,6 +261,10 @@ fr:
sport: Sport
matches_and_details: Matchs et détails
replay: Replays
concurrency_title: Tentatives de second direct
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours.
concurrency_none: Aucune tentative enregistrée pour ce club.
concurrency_all: Voir tous les abus de compte
comped:
title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
@@ -292,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é."
@@ -371,6 +418,10 @@ fr:
layer: Couche
layer_move: Mouvements souris
layer_click: Clics
devices:
mobile: Mobile
tablet: Tablette
desktop: Ordinateur
index:
title: Analytics du site
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
@@ -391,14 +442,24 @@ fr:
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
heatmap_moves: Carte des mouvements
heatmap_clicks: Carte des clics
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
heatmap_hint: Superposition colorée sur la capture %{device}. Vert = faible, rouge = fort. Les données ne sont pas mélangées entre appareils.
device_tabs_label: Appareil
preview_title: Capture de page
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez.
snapshot_missing: Pas encore de capture pour %{device}. Visitez la page sur cet appareil avec consentement analytics, puis réessayez.
snapshot_meta: "Capture %{device} · %{at}"
no_points: Aucun point agrégé pour cette couche sur la période.
heatmap_title: Carte des clics
no_clicks: Aucun clic agrégé pour cette page sur la période.
preview:
title: Aperçu sans suivi
lead: Parcourez le site public sans enregistrer pages vues, clics, mouvements ni captures heatmap. Utile pour tester en production.
active: Aperçu staff actif sur ce navigateur (7 jours ou jusqu'à désactivation).
enable: Activer l'aperçu staff
disable: Désactiver l'aperçu
open_site: Ouvrir le site
enabled: Aperçu staff activé. Ce navigateur ne enregistrera pas d'analytics.
disabled: Aperçu staff désactivé.
billing:
index:
+123 -2
View File
@@ -10,7 +10,9 @@ it:
billing: Fatturazione
youtube: YouTube
sessions: Sessioni
stream_concurrency: Abusi account
analytics: Analytics
costs: Costi
stream_nodes: Nodi stream
password: Password
logout: Esci
@@ -54,6 +56,9 @@ it:
announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato
announcement_destroyed: Avviso eliminato
cost_entry_created: Voce di costo registrata.
cost_entry_updated: Voce di costo aggiornata.
cost_entry_destroyed: Voce di costo eliminata.
common:
free_plan: Free
yes: "Sì"
@@ -103,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)"
@@ -193,6 +204,36 @@ it:
table:
resolved_at: Risolto
none: Nessun incidente risolto di recente.
stream_concurrency:
index:
title: Abusi account
lead: Tentativi di avviare una seconda diretta con lo stesso account mentre unaltra era già in corso (connecting, live, reconnecting o in pausa).
results: "Mostrate %{shown} di %{total}"
none: Nessun tentativo registrato.
kpi:
lookback: Ultimi 30 giorni
lookback_sub: tentativi bloccati
two_devices: Due dispositivi
two_devices_sub: modelli telefono diversi
filters:
q: Cerca
q_placeholder: Email, società, partita…
two_devices: Filtro
two_devices_hint: Solo tentativi da dispositivi diversi
apply: Filtra
reset: Reset
table:
when: Quando
account: Account
club: Società
occupying: Diretta già in corso
attempted: Tentativo bloccato
action:
start: avvio
resume: ripresa
badge:
two_devices: 2 telefoni
same_or_unknown: stesso device / n.d.
clubs:
index:
title: Società e squadre
@@ -225,6 +266,10 @@ it:
sport: Sport
matches_and_details: Partite e dettagli
replay: Replay
concurrency_title: Tentativi di seconda diretta
concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una.
concurrency_none: Nessun tentativo registrato per questa società.
concurrency_all: Vedi tutti gli abusi account
comped:
title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
@@ -313,6 +358,8 @@ it:
devices_title: Dispositivi
events_title: Eventi
events_none: Nessun evento registrato.
concurrency_title: Tentativi di seconda diretta
concurrency_lead: Questo account ha provato ad avviare unaltra diretta mentre questa era già in corso (o è stato bloccato da unaltra sessione dello stesso account).
status_label: "Stato:"
stop_button: Termina sessione
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
@@ -392,6 +439,10 @@ it:
layer: Livello
layer_move: Movimenti mouse
layer_click: Click
devices:
mobile: Mobile
tablet: Tablet
desktop: Desktop
index:
title: Analytics sito
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
@@ -412,14 +463,84 @@ it:
scroll_hint: "Media %{avg}% · massimo %{max}%"
heatmap_moves: Mappa movimenti mouse
heatmap_clicks: Mappa click
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
heatmap_hint: Overlay colorato sullo screenshot %{device}. Verde = poco, rosso = molto. I dati non vengono mischiati tra dispositivi.
device_tabs_label: Dispositivo
preview_title: Screenshot pagina
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo.
snapshot_missing: Nessuno screenshot per %{device}. Visita la pagina sul sito con quel dispositivo e consenso analytics, poi riprova tra qualche secondo.
snapshot_meta: "Screenshot %{device} · catturato %{at}"
no_points: Nessun punto aggregato per questo livello nel periodo.
heatmap_title: Mappa click
no_clicks: Nessun click aggregato per questa pagina nel periodo.
preview:
title: Anteprima senza tracciamento
lead: Visita il sito pubblico senza registrare pageview, click, movimenti o screenshot heatmap. Utile per test in produzione.
active: Modalità anteprima attiva su questo browser (7 giorni o fino a disattivazione).
enable: Attiva anteprima staff
disable: Disattiva anteprima
open_site: Apri sito
enabled: Anteprima staff attivata. Il sito non registrerà analytics su questo browser.
disabled: Anteprima staff disattivata.
costs:
index:
title: Analisi costi
lead: Inserisci i costi mensili della piattaforma e confronta costo, utilizzo e revenue. I KPI si ricalcolano automaticamente quando modifichi le voci.
month: Mese
apply: Applica
entries_title: Voci di costo del mese
entries_lead: Puoi aggiungere più voci (es. server, storage, banda). Il totale è la somma delle voci del mese.
add_entry: Aggiungi voce
no_entries: Nessuna voce di costo per questo mese. Aggiungi il costo della piattaforma per vedere i KPI.
clubs_title: Costo allocato per società
clubs_lead: Il costo infrastruttura è ripartito proporzionalmente alle ore trasmesse nel mese.
no_clubs: Nessuna diretta terminata in questo mese.
trends_title: Trend (ultimi 12 mesi)
kpi:
platform_cost: Costo piattaforma
revenue: Revenue incassata
margin: Margine
margin_pct: Margine %
hours: Ore trasmesse
sessions: Dirette
clubs_active: Società attive
storage: Storage replay
cost_per_hour: Costo / ora stream
cost_per_session: Costo / diretta
cost_per_club: Costo / società attiva
revenue_per_hour: Revenue / ora stream
cost_per_gb: Costo / GB storage
storage_note: Storage cumulativo attuale (non mensile).
charts:
cost_revenue: Costo vs revenue
cost_per_hour: Costo per ora trasmessa
hours: Ore trasmesse
table:
label: Voce
amount: Importo
notes: Note
club: Società
plan: Piano
hours: Ore
hours_share: Quota ore
allocated_cost: Costo allocato
revenue: Revenue
margin: Margine
cost_per_hour: €/ora
cost_per_session: €/diretta
storage: Storage
entries:
new_title: Nuova voce di costo
edit_title: Modifica voce di costo
month: Mese
label: Descrizione
label_hint: Es. Hetzner, nodi stream CPX, S3, banda egress.
amount: Importo (€)
notes: Note
save: Salva
cancel: Annulla
delete: Elimina
delete_confirm: Eliminare questa voce di costo?
billing:
index:
+11 -8
View File
@@ -1,4 +1,7 @@
de:
api:
errors:
user_concurrent_stream: "Mit diesem Konto läuft bereits eine Direktübertragung. Beende sie, bevor du eine weitere startest."
password_policy:
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
activerecord:
@@ -77,10 +80,10 @@ de:
title: "Verein registrieren — Match Live TV"
meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV.
heading: Dein Verein
lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, Serie C…)."
lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, erste Mannschaft…)."
section_club: Verein
name_label: Vereinsname / Club
name_placeholder: "z. B. Crazy Volley Rozzano"
name_placeholder: "z. B. Team MLTV"
section_first_team: Erstes Team
default_first_team_name: Erstes Team
first_team_name_label: Teamname
@@ -159,7 +162,7 @@ de:
heading: Neues Team
club_label: "Verein:"
name_label: Teamname
name_placeholder: "z. B. Under 13, Serie C"
name_placeholder: "z. B. U15 männlich"
branding_legend: Branding-Überschreibung (optional)
submit: Team hinzufügen
invite:
@@ -235,7 +238,7 @@ de:
matches:
back_to_list: "← Spieleliste"
opponent_label: Gegner
opponent_placeholder: "z. B. Volley Milano"
opponent_placeholder: "z. B. Team Guest"
location_label: "Ort (optional)"
location_placeholder: "Halle, Stadt"
datetime_label: Datum und Uhrzeit
@@ -361,7 +364,7 @@ de:
legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt."
entity_type_label: "Art des Rechnungsempfängers *"
legal_name_label: "Firmenname / Vor- und Nachname *"
legal_name_placeholder: "z. B. ASD Tigers Volley"
legal_name_placeholder: "z. B. Team MLTV"
vat_number_label: "USt-IdNr. * (Verein)"
fiscal_code_label: "Steuernummer * (Privatperson)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ de:
replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv"
schedule_match_link: "Spiel planen"
search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…"
search_placeholder_default: "Z. B. Crazy Volley, Serie D, Gegner…"
search_placeholder_default: "Z. B. Team MLTV, Team Guest…"
search_aria_label: "Team suchen"
search_button: "Suchen"
reset_link: "Zurücksetzen"
@@ -772,7 +775,7 @@ de:
empty_hero_cta_features: "Erfahre, wie es funktioniert"
demo_aria_label: "Beispiel einer aktiven Live-Übertragung"
demo_label: "Beispiel — so sieht eine aktive Übertragung aus"
demo_meta: "PalaTigers · Match Live TV"
demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Satz 2 · Sätze gewonnen 1-0"
show:
back_to_all: "← Alle Live-Übertragungen"
@@ -805,7 +808,7 @@ de:
back_to_live: "← Live-Übertragungen"
title: "Vergangene Live-Übertragungen"
hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele."
search_placeholder: "Z. B. Tigers Volley, Gegner, Verein, Ort…"
search_placeholder: "Z. B. Team MLTV, Team Guest…"
search_aria_label: "Wiederholung suchen"
search_button: "Suchen"
reset_link: "Zurücksetzen"
+11 -8
View File
@@ -1,4 +1,7 @@
en:
api:
errors:
user_concurrent_stream: "You already have a live stream running on this account. Stop it before starting another."
password_policy:
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
activerecord:
@@ -82,10 +85,10 @@ en:
title: "Register a club — Match Live TV"
meta_description: Create your sports club and its first team on Match Live TV.
heading: Your club
lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, Serie C…)."
lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, first team…)."
section_club: Club
name_label: Club name
name_placeholder: "e.g. Crazy Volley Rozzano"
name_placeholder: "e.g. Team MLTV"
section_first_team: First team
default_first_team_name: First team
first_team_name_label: Team name
@@ -164,7 +167,7 @@ en:
heading: New team
club_label: "Club:"
name_label: Team name
name_placeholder: "e.g. Under 13, Serie C"
name_placeholder: "e.g. U15 boys"
branding_legend: Branding override (optional)
submit: Add team
invite:
@@ -240,7 +243,7 @@ en:
matches:
back_to_list: "← Match list"
opponent_label: Opponent
opponent_placeholder: "e.g. Volley Milano"
opponent_placeholder: "e.g. Team Guest"
location_label: "Location (optional)"
location_placeholder: "Gym, city"
datetime_label: Date and time
@@ -366,7 +369,7 @@ en:
legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe."
entity_type_label: "Billing entity type *"
legal_name_label: "Legal name / full name *"
legal_name_placeholder: "e.g. ASD Tigers Volley"
legal_name_placeholder: "e.g. Team MLTV"
vat_number_label: "VAT number * (club)"
fiscal_code_label: "Fiscal code * (private individual)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -743,7 +746,7 @@ en:
replay_archive_link: "Past live streams — replay archive"
schedule_match_link: "Schedule a match"
search_placeholder_club: "Search club, team, opponent or venue…"
search_placeholder_default: "E.g. Crazy Volley, Serie D, opponent…"
search_placeholder_default: "E.g. Team MLTV, Team Guest…"
search_aria_label: "Search team"
search_button: "Search"
reset_link: "Reset"
@@ -777,7 +780,7 @@ en:
empty_hero_cta_features: "See how it works"
demo_aria_label: "Example of an active live stream"
demo_label: "Example — this is how an active stream looks"
demo_meta: "PalaTigers · Match Live TV"
demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets won 1-0"
show:
back_to_all: "← All live streams"
@@ -810,7 +813,7 @@ en:
back_to_live: "← Live streams"
title: "Past live streams"
hint: "Public replays from sports clubs — covers matches already broadcast."
search_placeholder: "E.g. Tigers Volley, opponent, club, venue…"
search_placeholder: "E.g. Team MLTV, Team Guest…"
search_aria_label: "Search replays"
search_button: "Search"
reset_link: "Reset"
+11 -8
View File
@@ -1,4 +1,7 @@
es:
api:
errors:
user_concurrent_stream: "Ya tienes un directo en curso con esta cuenta. Ciérralo antes de iniciar otro."
password_policy:
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
activerecord:
@@ -77,10 +80,10 @@ es:
title: "Registrar un club — Match Live TV"
meta_description: Crea tu club deportivo y su primer equipo en Match Live TV.
heading: Tu club
lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, Serie C…)."
lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, primer equipo…)."
section_club: Club
name_label: Nombre del club
name_placeholder: "ej. Crazy Volley Rozzano"
name_placeholder: "ej. Team MLTV"
section_first_team: Primer equipo
default_first_team_name: Primer equipo
first_team_name_label: Nombre del equipo
@@ -159,7 +162,7 @@ es:
heading: Nuevo equipo
club_label: "Club:"
name_label: Nombre del equipo
name_placeholder: "ej. Sub-13, Serie C"
name_placeholder: "ej. U15 masculino"
branding_legend: Personalización de imagen de marca (opcional)
submit: Añadir equipo
invite:
@@ -235,7 +238,7 @@ es:
matches:
back_to_list: "← Lista de partidos"
opponent_label: Rival
opponent_placeholder: "ej. Volley Milano"
opponent_placeholder: "ej. Team Guest"
location_label: "Lugar (opcional)"
location_placeholder: "Pabellón, ciudad"
datetime_label: Fecha y hora
@@ -361,7 +364,7 @@ es:
legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe."
entity_type_label: "Tipo de titular *"
legal_name_label: "Razón social / nombre y apellidos *"
legal_name_placeholder: "ej. ASD Tigers Volley"
legal_name_placeholder: "ej. Team MLTV"
vat_number_label: "NIF/CIF * (club)"
fiscal_code_label: "Código fiscal * (persona física)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ es:
replay_archive_link: "Directos pasados — archivo de repeticiones"
schedule_match_link: "Programar partido"
search_placeholder_club: "Buscar club, equipo, rival o lugar…"
search_placeholder_default: "Ej. Crazy Volley, Serie D, rival…"
search_placeholder_default: "Ej. Team MLTV, Team Guest…"
search_aria_label: "Buscar equipo"
search_button: "Buscar"
reset_link: "Restablecer"
@@ -772,7 +775,7 @@ es:
empty_hero_cta_features: "Descubre cómo funciona"
demo_aria_label: "Ejemplo de un directo activo"
demo_label: "Ejemplo — así se ve un directo activo"
demo_meta: "PalaTigers · Match Live TV"
demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets ganados 1-0"
show:
back_to_all: "← Todos los directos"
@@ -805,7 +808,7 @@ es:
back_to_live: "← Directos en curso"
title: "Directos pasados"
hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos."
search_placeholder: "Ej. Tigers Volley, rival, club, lugar…"
search_placeholder: "Ej. Team MLTV, Team Guest…"
search_aria_label: "Buscar repetición"
search_button: "Buscar"
reset_link: "Restablecer"
+11 -8
View File
@@ -1,4 +1,7 @@
fr:
api:
errors:
user_concurrent_stream: "Un direct est déjà en cours avec ce compte. Arrêtez-le avant den démarrer un autre."
password_policy:
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
activerecord:
@@ -77,10 +80,10 @@ fr:
title: "Inscrire un club — Match Live TV"
meta_description: Crée ton club sportif et sa première équipe sur Match Live TV.
heading: Ton club
lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, Serie C…)."
lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, première équipe…)."
section_club: Club
name_label: Nom du club
name_placeholder: "ex. Crazy Volley Rozzano"
name_placeholder: "ex. Team MLTV"
section_first_team: Première équipe
default_first_team_name: Première équipe
first_team_name_label: Nom de l'équipe
@@ -159,7 +162,7 @@ fr:
heading: Nouvelle équipe
club_label: "Club :"
name_label: Nom de l'équipe
name_placeholder: "ex. Under 13, Serie C"
name_placeholder: "ex. U15 masculin"
branding_legend: Personnalisation de l'image de marque (facultatif)
submit: Ajouter l'équipe
invite:
@@ -235,7 +238,7 @@ fr:
matches:
back_to_list: "← Liste des matchs"
opponent_label: Adversaire
opponent_placeholder: "ex. Volley Milano"
opponent_placeholder: "ex. Team Guest"
location_label: "Lieu (facultatif)"
location_placeholder: "Gymnase, ville"
datetime_label: Date et heure
@@ -361,7 +364,7 @@ fr:
legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe."
entity_type_label: "Type de titulaire *"
legal_name_label: "Raison sociale / nom et prénom *"
legal_name_placeholder: "ex. ASD Tigers Volley"
legal_name_placeholder: "ex. Team MLTV"
vat_number_label: "Numéro de TVA * (club)"
fiscal_code_label: "Code fiscal * (personne physique)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -738,7 +741,7 @@ fr:
replay_archive_link: "Directs passés — archive des replays"
schedule_match_link: "Programmer un match"
search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…"
search_placeholder_default: "Ex. Crazy Volley, Série D, adversaire…"
search_placeholder_default: "Ex. Team MLTV, Team Guest…"
search_aria_label: "Rechercher une équipe"
search_button: "Rechercher"
reset_link: "Réinitialiser"
@@ -772,7 +775,7 @@ fr:
empty_hero_cta_features: "Découvrez comment ça marche"
demo_aria_label: "Exemple de direct actif"
demo_label: "Exemple — voici à quoi ressemble un direct actif"
demo_meta: "PalaTigers · Match Live TV"
demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Sets gagnés 1-0"
show:
back_to_all: "← Tous les directs"
@@ -805,7 +808,7 @@ fr:
back_to_live: "← Directs en cours"
title: "Directs passés"
hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés."
search_placeholder: "Ex. Tigers Volley, adversaire, club, lieu…"
search_placeholder: "Ex. Team MLTV, Team Guest…"
search_aria_label: "Rechercher un replay"
search_button: "Rechercher"
reset_link: "Réinitialiser"
+11 -8
View File
@@ -1,4 +1,7 @@
it:
api:
errors:
user_concurrent_stream: "Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra."
password_policy:
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
activerecord:
@@ -82,10 +85,10 @@ it:
title: "Registra società — Match Live TV"
meta_description: Crea la società sportiva e la prima squadra su Match Live TV.
heading: La tua società
lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, Serie C…)."
lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, prima squadra…)."
section_club: Società
name_label: Nome società / club
name_placeholder: "es. Crazy Volley Rozzano"
name_placeholder: "es. Team MLTV"
section_first_team: Prima squadra
default_first_team_name: Prima squadra
first_team_name_label: Nome squadra
@@ -164,7 +167,7 @@ it:
heading: Nuova squadra
club_label: "Società:"
name_label: Nome squadra
name_placeholder: "es. Under 13, Serie C"
name_placeholder: "es. U15 maschile"
branding_legend: Override branding (opzionale)
submit: Aggiungi squadra
invite:
@@ -240,7 +243,7 @@ it:
matches:
back_to_list: "← Elenco partite"
opponent_label: Avversario
opponent_placeholder: "es. Volley Milano"
opponent_placeholder: "es. Squadra ospite"
location_label: "Luogo (opzionale)"
location_placeholder: "Palestra, città"
datetime_label: Data e ora
@@ -367,7 +370,7 @@ it:
legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe."
entity_type_label: "Tipo intestatario *"
legal_name_label: "Ragione sociale / nome e cognome *"
legal_name_placeholder: "es. ASD Tigers Volley"
legal_name_placeholder: "es. Team MLTV"
vat_number_label: "Partita IVA * (società)"
fiscal_code_label: "Codice Fiscale * (persona fisica)"
fiscal_code_placeholder: RSSMRA80A01H501U
@@ -771,7 +774,7 @@ it:
replay_archive_link: "Live passate — archivio replay"
schedule_match_link: "Programma partita"
search_placeholder_club: "Cerca società, squadra, avversario o luogo…"
search_placeholder_default: "Es. Crazy Volley, Serie D, avversario…"
search_placeholder_default: "Es. Team MLTV, Squadra ospite…"
search_aria_label: "Cerca squadra"
search_button: "Cerca"
reset_link: "Azzera"
@@ -805,7 +808,7 @@ it:
empty_hero_cta_features: "Scopri come funziona"
demo_aria_label: "Esempio di diretta attiva"
demo_label: "Esempio — così appare una diretta attiva"
demo_meta: "PalaTigers · Match Live TV"
demo_meta: "Pala MLTV · Match Live TV"
demo_sets: "Set 2 · Set vinti 1-0"
show:
back_to_all: "← Tutte le dirette"
@@ -838,7 +841,7 @@ it:
back_to_live: "← Dirette live"
title: "Live passate"
hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse."
search_placeholder: "Es. Tigers Volley, avversario, società, luogo…"
search_placeholder: "Es. Team MLTV, Squadra ospite…"
search_aria_label: "Cerca replay"
search_button: "Cerca"
reset_link: "Azzera"
+20
View File
@@ -0,0 +1,20 @@
it:
demo:
away_team: Squadra ospite
when: "Sabato 12 settembre · 10:30"
en:
demo:
away_team: Team Guest
when: "Saturday 12 September · 10:30"
de:
demo:
away_team: Team Guest
when: "Samstag, 12. September · 10:30"
fr:
demo:
away_team: Team Guest
when: "Samedi 12 septembre · 10:30"
es:
demo:
away_team: Team Guest
when: "Sábado 12 de septiembre · 10:30"
+7
View File
@@ -111,6 +111,13 @@ de:
s3_pricing_link_text: Preise
s3_p2_html: "Zahlungen für kostenpflichtige Pakete werden über <strong>Stripe</strong> abgewickelt. Mit Abschluss des Checkouts akzeptieren Sie auch die Zahlungsbedingungen von Stripe. Der Anbieter speichert keine vollständigen Kartendaten."
s3_p3: Verlängerungen, Kündigung und Widerrufsrecht richten sich nach den beim Kauf mitgeteilten Angaben und den geltenden Rechtsvorschriften.
s3b_title: 3-bis. Garantie „Zufrieden oder Geld zurück“
s3b_p1: Die Pakete Premium Light und Premium Full sind durch eine Garantie „Zufrieden oder Geld zurück“ von 30 Tagen ab dem Aktivierungsdatum des ersten von dem Verein (Kunden) erworbenen Match-Live-TV-Abos abgedeckt.
s3b_p2: Wenn Sie in diesem Zeitraum der Auffassung sind, dass der Dienst den Bedürfnissen des Vereins nicht entspricht, können Sie die Erstattung von 100 % des tatsächlich für dieses erste Abo gezahlten Betrags beantragen. Der erstattete Betrag entspricht der vollständig gezahlten Summe; etwaige Zahlungsgebühren trägt der Anbieter.
s3b_p3_html: "Der Antrag ist innerhalb von 30 Tagen nach der Aktivierung zu stellen, per E-Mail an %{email_link} oder über die auf der Website angegebenen Support-Kanäle, unter Angabe des Vereins und der Abo-Daten."
s3b_p4: Die Erstattung erfolgt, soweit technisch möglich, auf dasselbe Zahlungsmittel, das beim Kauf verwendet wurde. Die Gutschriftzeiten hängen auch vom Zahlungsnetz ab; der Anbieter leitet die Bearbeitung in der Regel innerhalb von 14 Tagen nach Bestätigung des Antrags ein.
s3b_p5: Die Garantie gilt nur einmal, für das erste vom Verein erworbene Match-Live-TV-Abo. Sie verlängert sich nicht automatisch auf spätere Zeiträume und darf nicht wiederholt von demselben Verein durch Anlegen neuer Konten in Anspruch genommen werden.
s3b_p6: Die Garantie soll dem Verein ermöglichen, Match Live TV in den ersten 30 Tagen im normalen Gebrauch zu prüfen. Der Anbieter behält sich vor, ihre Anwendung ausschließlich bei Nutzungen auszuschließen, die diesem Zweck eindeutig fremd sind, etwa einer vorübergehenden Aktivierung allein zur intensiven Abdeckung eines einzelnen Events oder Turniers, gefolgt von einem Erstattungsantrag.
s4_title: 4. Livestreams, Inhalte und Schutz Minderjähriger
s4_lead1: Verantwortung des Sportvereins
s4_p1_html: "Der Verein, der einen Livestream startet, ist verantwortlich für die übertragenen Inhalte (Bilder, Audio, Kommentare) sowie für die Einhaltung der Gesetze, des Verbandsreglements und der erforderlichen Einwilligungen, insbesondere wenn <strong>minderjährige Athleten</strong> gefilmt werden."
+7
View File
@@ -111,6 +111,13 @@ en:
s3_pricing_link_text: Pricing
s3_p2_html: "Payments for paid plans are handled by <strong>Stripe</strong>. By completing checkout, you also accept Stripe's terms for payment. The Provider does not store full card details."
s3_p3: Renewals, cancellation and the right of withdrawal follow what was communicated at the time of purchase and applicable law.
s3b_title: 3-bis. Satisfied or refunded guarantee
s3b_p1: Premium Light and Premium Full plans are covered by a “Satisfied or refunded” guarantee lasting 30 days from the activation date of the first Match Live TV subscription purchased by the club (customer).
s3b_p2: If within that period you consider the service is not suitable for the clubs needs, you may request a refund of 100% of the amount actually paid for that first subscription. The refunded amount equals the full sum paid; any payment processing fees remain borne by the Provider.
s3b_p3_html: "The request must be sent within 30 days of activation, by writing to %{email_link} or via the support channels indicated on the site, stating the club and the subscription details."
s3b_p4: The refund is made, where technically possible, to the same payment method used for the purchase. Credit times also depend on the payment network; the Provider normally starts processing within 14 days of confirming the request.
s3b_p5: The guarantee applies only once, to the first Match Live TV subscription purchased by the club. It does not automatically renew for later periods and cannot be used repeatedly by the same club through the creation of new accounts.
s3b_p6: The guarantee is intended to allow the club to evaluate Match Live TV in normal use during the first 30 days. The Provider reserves the right to exclude its application solely in cases of use clearly unrelated to that purpose, such as a temporary activation aimed exclusively at intensive coverage of a single event or tournament, followed by a refund request.
s4_title: 4. Live broadcasts, content and protection of minors
s4_lead1: Responsibility of the sports club
s4_p1_html: "The club that starts a live broadcast is responsible for the content transmitted (images, audio, comments) and for compliance with the law, federation regulations and the necessary consents, in particular when <strong>minor athletes</strong> are filmed."
+7
View File
@@ -111,6 +111,13 @@ es:
s3_pricing_link_text: Precios
s3_p2_html: "Los pagos de los planes de pago se gestionan a través de <strong>Stripe</strong>. Al aceptar el checkout, también aceptas los términos de Stripe para el pago. El Proveedor no almacena los datos completos de la tarjeta."
s3_p3: Las renovaciones, la baja y el derecho de desistimiento siguen lo comunicado en el momento de la compra y la normativa aplicable.
s3b_title: 3-bis. Garantía Satisfechos o reembolsados
s3b_p1: Los planes Premium Light y Premium Full están cubiertos por una garantía «Satisfechos o reembolsados» de 30 días desde la fecha de activación de la primera suscripción Match Live TV adquirida por el club (cliente).
s3b_p2: Si en ese periodo consideras que el servicio no se adapta a las necesidades del club, puedes solicitar el reembolso del 100% del importe efectivamente pagado por esa primera suscripción. El importe reembolsado equivale a la suma íntegra abonada; las eventuales comisiones de pago corren a cargo del Proveedor.
s3b_p3_html: "La solicitud debe enviarse dentro de los 30 días desde la activación, escribiendo a %{email_link} o a través de los canales de soporte indicados en el sitio, indicando el club y los datos de la suscripción."
s3b_p4: El reembolso se efectúa, cuando sea técnicamente posible, en el mismo método de pago utilizado en la compra. Los plazos de abono también dependen de la red de pago; el Proveedor suele iniciar el trámite en un plazo de 14 días desde la confirmación de la solicitud.
s3b_p5: La garantía se aplica una sola vez, a la primera suscripción Match Live TV adquirida por el club. No se renueva automáticamente en periodos posteriores y no puede utilizarse de forma reiterada por el mismo club mediante la creación de nuevas cuentas.
s3b_p6: La garantía está pensada para que el club evalúe Match Live TV en un uso normal durante los primeros 30 días. El Proveedor se reserva el derecho de excluir su aplicación únicamente en caso de usos manifiestamente ajenos a esa finalidad, como una activación temporal destinada exclusivamente a la cobertura intensiva de un evento o torneo aislado, seguida de una solicitud de reembolso.
s4_title: 4. Directos, contenidos y protección de menores
s4_lead1: Responsabilidad del club deportivo
s4_p1_html: "El club que inicia un directo es responsable de los contenidos transmitidos (imágenes, audio, comentarios) y del cumplimiento de las leyes, el reglamento federativo y los consentimientos necesarios, en particular cuando se filma a <strong>deportistas menores de edad</strong>."
+7
View File
@@ -111,6 +111,13 @@ fr:
s3_pricing_link_text: Tarifs
s3_p2_html: "Les paiements des forfaits payants sont gérés par <strong>Stripe</strong>. En validant le paiement, vous acceptez également les conditions de Stripe pour le paiement. Le Fournisseur ne conserve pas les données complètes de la carte."
s3_p3: Les renouvellements, la résiliation et le droit de rétractation suivent ce qui a été communiqué au moment de l'achat et la réglementation applicable.
s3b_title: 3-bis. Garantie Satisfait ou remboursé
s3b_p1: Les forfaits Premium Light et Premium Full sont couverts par une garantie « Satisfait ou remboursé » d'une durée de 30 jours à compter de la date d'activation du premier abonnement Match Live TV acheté par le club (client).
s3b_p2: Si dans ce délai vous estimez que le service ne convient pas aux besoins du club, vous pouvez demander le remboursement de 100 % du montant effectivement payé pour ce premier abonnement. Le montant remboursé correspond à la somme intégralement versée ; d'éventuelles commissions de paiement restent à la charge du Fournisseur.
s3b_p3_html: "La demande doit être envoyée dans les 30 jours suivant l'activation, en écrivant à %{email_link} ou via les canaux d'assistance indiqués sur le site, en précisant le club et les références de l'abonnement."
s3b_p4: Le remboursement est effectué, lorsque c'est techniquement possible, sur le même moyen de paiement que celui utilisé pour l'achat. Les délais de crédit dépendent aussi du réseau de paiement ; le Fournisseur engage en principe le traitement dans les 14 jours suivant la confirmation de la demande.
s3b_p5: La garantie s'applique une seule fois, au premier abonnement Match Live TV acheté par le club. Elle ne se renouvelle pas automatiquement sur les périodes suivantes et ne peut pas être utilisée de manière répétée par le même club via la création de nouveaux comptes.
s3b_p6: La garantie vise à permettre au club d'évaluer Match Live TV dans un usage normal pendant les 30 premiers jours. Le Fournisseur se réserve le droit d'en exclure l'application uniquement en cas d'usages manifestement étrangers à cette finalité, tels qu'une activation temporaire destinée exclusivement à la couverture intensive d'un événement ou d'un tournoi isolé, suivie d'une demande de remboursement.
s4_title: 4. Directs, contenus et protection des mineurs
s4_lead1: Responsabilité du club sportif
s4_p1_html: "Le club qui lance un direct est responsable des contenus diffusés (images, audio, commentaires) et de la conformité aux lois, au règlement fédéral et aux consentements nécessaires, en particulier lorsque des <strong>athlètes mineurs</strong> sont filmés."
+7
View File
@@ -111,6 +111,13 @@ it:
s3_pricing_link_text: Prezzi
s3_p2_html: "I pagamenti dei piani a pagamento sono gestiti da <strong>Stripe</strong>. Accettando il checkout, accetti anche i termini di Stripe per il pagamento. Il Fornitore non memorizza i dati completi della carta."
s3_p3: Rinnovi, disdetta e diritto di recesso seguono quanto comunicato al momento dellacquisto e la normativa applicabile.
s3b_title: 3-bis. Garanzia Soddisfatti o rimborsati
s3b_p1: I piani Premium Light e Premium Full sono coperti da una garanzia «Soddisfatti o rimborsati» della durata di 30 giorni dalla data di attivazione del primo abbonamento Match Live TV acquistato dalla società (cliente).
s3b_p2: Se entro tale periodo ritieni che il servizio non sia adatto alle esigenze della società, puoi richiedere il rimborso del 100% dellimporto effettivamente pagato per quel primo abbonamento. Limporto rimborsato è pari allintera somma versata; eventuali commissioni di pagamento restano a carico del Fornitore.
s3b_p3_html: "La richiesta va inviata entro i 30 giorni dallattivazione, scrivendo a %{email_link} oppure tramite i canali di supporto indicati sul sito, indicando la società e i riferimenti dellabbonamento."
s3b_p4: Il rimborso è disposto, ove tecnicamente possibile, sullo stesso metodo di pagamento utilizzato per lacquisto. I tempi di accredito dipendono anche dal circuito di pagamento; di norma il Fornitore avvia lelaborazione entro 14 giorni dalla conferma della richiesta.
s3b_p5: La garanzia si applica una sola volta, al primo abbonamento Match Live TV acquistato dalla società. Non si rinnova automaticamente su periodi successivi e non può essere utilizzata ripetutamente dalla stessa società tramite la creazione di nuovi account.
s3b_p6: La garanzia è destinata a consentire alla società di valutare Match Live TV nel normale utilizzo durante i primi 30 giorni. Il Fornitore si riserva di escluderne lapplicazione esclusivamente in caso di utilizzi manifestamente estranei a tale finalità, quali lattivazione temporanea finalizzata esclusivamente alla copertura intensiva di un singolo evento o torneo, seguita dalla richiesta di rimborso.
s4_title: 4. Dirette, contenuti e protezione dei minori
s4_lead1: Responsabilità della società sportiva
s4_p1_html: "La società che avvia una diretta è responsabile dei contenuti trasmessi (immagini, audio, commenti) e della conformità alle leggi, al regolamento federale e ai consensi necessari, in particolare quando sono ripresi <strong>atleti minorenni</strong>."
+22 -5
View File
@@ -59,11 +59,8 @@ de:
replay_item_download: Video bei Bedarf aufs Handy herunterladen
replay_card_title: Spielarchiv
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Auch das gehört zum Produkt
more_stable_title: Livestream vom Smartphone
@@ -76,6 +73,19 @@ de:
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
cta_primary: Kostenlos starten
cta_secondary: Pläne vergleichen
sponsor_cover:
eyebrow: Premium Full
title: Gib deinen Sponsoren Sichtbarkeit
body: Mit Premium Full kannst du das Titelbild jedes Spiels anpassen, indem du eine Grafik eures Vereins hochlädst. Darin könnt ihr Sponsoren, Partner, Logos und Botschaften unterbringen und ihnen schon vor dem Livestream Sichtbarkeit geben.
item_cover: Titelbild für jedes Spiel hochladen
item_before: Grafik sichtbar im Pre-Live
item_club: Die Identität eures Vereins, kein generisches Template
claim: Euer Verein. Eure Sponsoren. Euer Livestream.
cta: Premium Full entdecken
mock:
state: PRE-LIVE
art_label: Individuelles Titelbild
soon: Der Livestream beginnt in Kürze
pricing:
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
@@ -88,8 +98,9 @@ de:
table_matches: Gleichzeitige Spiele
table_live_mltv: Live auf Match Live TV
table_youtube: YouTube
table_replay: Server-Replay
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}"
@@ -137,7 +149,7 @@ de:
q3_answer_html: "Ja. Die Satz-Zählweise ist für Volleyball gemacht; Sie können die Regeln für besondere Turniere anpassen. Entdecken Sie die eigene Seite: %{volleyball_link}."
q3_volleyball_link: Match Live TV für Jugendvolleyball
q4_question: Was passiert, wenn ich den Livestream verpasse?
q4_answer: "Mit den Plänen Premium Light oder Full wird das Spiel im Archiv gespeichert (30 oder 90 Tage) und Sie können es auf der Website erneut ansehen. Der Free-Plan enthält kein Server-Replay, aber der Livestream bleibt für Streamer und Zuschauer kostenlos."
q4_answer: "Mit den Plänen Premium Light oder Full wird das Spiel im Archiv gespeichert (30 oder 90 Tage) und Sie können es auf der Website erneut ansehen. Der Free-Plan enthält kein Replay-Archiv, aber der Livestream bleibt für Streamer und Zuschauer kostenlos."
q5_question: Was kostet es für den Verein?
q5_answer_html: "Sie können mit dem <strong>Free</strong>-Plan starten (begrenztes Team und ein Livestream gleichzeitig). Premium Light und Full bieten mehr parallele Spiele, Archiv und YouTube. %{pricing_link}."
q5_pricing_link: Preise vergleichen
@@ -147,6 +159,11 @@ de:
q7_answer: "Mit Premium Light können Sie ihn auf den Match-Live-TV-Kanal senden; mit Premium Full auch auf den YouTube-Kanal des Vereins. Wer möchte, bleibt beim Match-Live-TV-Link, praktisch für Familien."
q8_question: Muss ich Router-Ports öffnen oder TV-Ausrüstung haben?
q8_answer: "Nein, nicht für Zuschauer. Für den streamenden Verein reichen ein Smartphone und eine gute Verbindung in der Halle; das technische Team der Plattform kümmert sich um die Infrastruktur. Keine Broadcast-Kameras oder Mischpulte nötig."
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:
+22 -5
View File
@@ -59,11 +59,8 @@ en:
replay_item_download: Download the video to your phone when you need it
replay_card_title: Match archive
replay_card_body: Rewatch matches after the final whistle and download them when you need them.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Also part of the product
more_stable_title: Stream from your smartphone
@@ -76,6 +73,19 @@ en:
cta_body: Try Match Live TV for free. No dedicated equipment needed.
cta_primary: Start for free
cta_secondary: Compare plans
sponsor_cover:
eyebrow: Premium Full
title: Give your sponsors visibility
body: With Premium Full you can customise each match cover by uploading artwork from your club. You can include sponsors, partners, logos and messages in that graphic, giving them visibility before the live stream starts.
item_cover: A cover uploaded for every match
item_before: Artwork shown in the pre-live screen
item_club: Your clubs identity, not a generic template
claim: Your club. Your sponsors. Your stream.
cta: Discover Premium Full
mock:
state: PRE-LIVE
art_label: Custom cover
soon: The live stream will start shortly
pricing:
meta_title: "Youth match streaming pricing — Match Live TV"
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
@@ -88,8 +98,9 @@ en:
table_matches: Concurrent matches
table_live_mltv: Live on Match Live TV
table_youtube: YouTube
table_replay: Server replay
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}"
@@ -137,7 +149,7 @@ en:
q3_answer_html: "Yes. The set-based scoring is built for volleyball; you can customize the rules for special tournaments. Check out the dedicated page: %{volleyball_link}."
q3_volleyball_link: Match Live TV for youth volleyball
q4_question: What happens if I miss the stream?
q4_answer: "With Premium Light or Full plans the match is saved to the archive (30 or 90 days) and you can watch it again from the site. The Free plan doesn't include server replay, but the live stream stays free for both streamers and viewers."
q4_answer: "With Premium Light or Full plans the match is saved to the archive (30 or 90 days) and you can watch it again from the site. The Free plan doesn't include the replay archive, but the live stream stays free for both streamers and viewers."
q5_question: How much does it cost for the club?
q5_answer_html: "You can start with the <strong>Free</strong> plan (limited staff and one stream at a time). Premium Light and Full add more concurrent matches, archive and YouTube. %{pricing_link}."
q5_pricing_link: Compare pricing
@@ -147,6 +159,11 @@ en:
q7_answer: "With Premium Light you can send it to the Match Live TV channel; with Premium Full also to the club's YouTube channel. Those who prefer can stick to the Match Live TV link, convenient for families."
q8_question: Do I need to open router ports or have TV equipment?
q8_answer: "No, not for viewers. For the club that streams, a smartphone and a good gym connection are enough; the platform's technical staff manages the infrastructure. No broadcast cameras or mixers needed."
q9_question: How does the Satisfied or refunded guarantee work?
q9_answer_html: "You can try Match Live TV with your club for 30 days from activation of the first subscription. If within that period you feel the platform is not a good fit, you can request a refund of the full amount paid. For more details see the %{terms_link}."
q9_terms_link: Terms and Conditions
q10_question: Can I include the clubs sponsors in the broadcasts?
q10_answer: With Premium Full you can upload a custom cover for the match. The club prepares its own artwork and can include sponsors, partners, logos and messages in that graphic. The cover is shown before the live stream starts, giving visibility to the organisations that support the club.
cta_signup: Register your team
cta_live: Watch live matches
volleyball:
+22 -5
View File
@@ -59,11 +59,8 @@ es:
replay_item_download: Descarga del vídeo al móvil cuando haga falta
replay_card_title: Archivo de partidos
replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: También forma parte del producto
more_stable_title: Directo desde el smartphone
@@ -76,6 +73,19 @@ es:
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
cta_primary: Empieza gratis
cta_secondary: Compara los planes
sponsor_cover:
eyebrow: Premium Full
title: Da visibilidad a tus patrocinadores
body: Con Premium Full puedes personalizar la portada de cada partido subiendo una gráfica de tu club. Así puedes incluir patrocinadores, partners, logotipos y mensajes, y darles visibilidad ya antes de que empiece el directo.
item_cover: Portada cargada para cada partido
item_before: Gráfica visible en el pre-directo
item_club: La identidad de tu club, no una plantilla genérica
claim: Tu club. Tus patrocinadores. Tu directo.
cta: Descubre Premium Full
mock:
state: PRE-LIVE
art_label: Portada personalizada
soon: El directo comenzará en breve
pricing:
meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
@@ -88,8 +98,9 @@ es:
table_matches: Partidos simultáneos
table_live_mltv: Directo en Match Live TV
table_youtube: YouTube
table_replay: Repetición en servidor
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}"
@@ -137,7 +149,7 @@ es:
q3_answer_html: "Sí. El marcador por sets está pensado para el voleibol; puedes personalizar reglas para torneos especiales. Descubre la página dedicada: %{volleyball_link}."
q3_volleyball_link: Match Live TV para voleibol juvenil
q4_question: "¿Qué pasa si me pierdo el directo?"
q4_answer: "Con los planes Premium Light o Full el partido se guarda en el archivo (30 o 90 días) y puedes volver a verlo desde el sitio. El plan Free no incluye repetición en servidor, pero el directo sigue siendo gratis para quien emite y para quien mira."
q4_answer: "Con los planes Premium Light o Full el partido se guarda en el archivo (30 o 90 días) y puedes volver a verlo desde el sitio. El plan Free no incluye archivo de replay, pero el directo sigue siendo gratis para quien emite y para quien mira."
q5_question: "¿Cuánto cuesta para el club?"
q5_answer_html: "Puedes empezar con el plan <strong>Free</strong> (límites de staff y un directo a la vez). Premium Light y Full añaden más partidos en paralelo, archivo y YouTube. %{pricing_link}."
q5_pricing_link: Compara precios
@@ -147,6 +159,11 @@ es:
q7_answer: "Con Premium Light puedes enviarlo al canal de Match Live TV; con Premium Full también al canal de YouTube del club. Quien prefiera puede quedarse con el enlace de Match Live TV, cómodo para las familias."
q8_question: "¿Tengo que abrir puertos en el router o tener equipo de TV?"
q8_answer: "No, para quien mira no. Para el club que emite basta con el smartphone y una buena conexión en el pabellón; el staff técnico de la plataforma gestiona la infraestructura. Nada de cámaras de retransmisión ni mezcladores."
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:
+22 -5
View File
@@ -59,11 +59,8 @@ fr:
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
replay_card_title: Archive des matchs
replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Cela fait aussi partie du produit
more_stable_title: Direct depuis le smartphone
@@ -76,6 +73,19 @@ fr:
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
cta_primary: Commencer gratuitement
cta_secondary: Comparer les offres
sponsor_cover:
eyebrow: Premium Full
title: Donnez de la visibilité à vos sponsors
body: Avec Premium Full, vous pouvez personnaliser la jaquette de chaque match en chargeant un graphisme de votre club. Vous pouvez y intégrer sponsors, partenaires, logos et messages, et leur donner de la visibilité avant le début du direct.
item_cover: Jaquette chargée pour chaque match
item_before: Graphisme visible en pré-direct
item_club: Lidentité de votre club, pas un modèle générique
claim: Votre club. Vos sponsors. Votre direct.
cta: Découvrir Premium Full
mock:
state: PRE-LIVE
art_label: Jaquette personnalisée
soon: Le direct va bientôt commencer
pricing:
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
@@ -88,8 +98,9 @@ fr:
table_matches: Matchs simultanés
table_live_mltv: Direct sur Match Live TV
table_youtube: YouTube
table_replay: Replay serveur
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}"
@@ -137,7 +149,7 @@ fr:
q3_answer_html: "Oui. Le score par set est pensé pour le volley ; vous pouvez personnaliser les règles pour des tournois particuliers. Découvrez la page dédiée : %{volleyball_link}."
q3_volleyball_link: Match Live TV pour le volley jeunes
q4_question: Que se passe-t-il si je rate le direct ?
q4_answer: "Avec les offres Premium Light ou Full, le match est enregistré dans l'archive (30 ou 90 jours) et vous pouvez le revoir depuis le site. L'offre Free n'inclut pas le replay serveur, mais le direct reste gratuit pour ceux qui diffusent et pour ceux qui regardent."
q4_answer: "Avec les offres Premium Light ou Full, le match est enregistré dans l'archive (30 ou 90 jours) et vous pouvez le revoir depuis le site. L'offre Free n'inclut pas l'archive replay, mais le direct reste gratuit pour ceux qui diffusent et pour ceux qui regardent."
q5_question: Combien ça coûte pour le club ?
q5_answer_html: "Vous pouvez commencer avec l'offre <strong>Free</strong> (limites sur le staff et un direct à la fois). Premium Light et Full ajoutent plus de matchs en parallèle, l'archive et YouTube. %{pricing_link}."
q5_pricing_link: Comparer les tarifs
@@ -147,6 +159,11 @@ fr:
q7_answer: "Avec Premium Light, vous pouvez le diffuser sur la chaîne Match Live TV ; avec Premium Full, aussi sur la chaîne YouTube du club. Ceux qui préfèrent peuvent rester sur le lien Match Live TV, pratique pour les familles."
q8_question: Dois-je ouvrir des ports sur le routeur ou avoir du matériel TV ?
q8_answer: "Non, pas pour les spectateurs. Pour le club qui diffuse, un smartphone et une bonne connexion en salle suffisent ; le staff technique de la plateforme gère l'infrastructure. Pas de caméras de diffusion ni de mixeur."
q9_question: Comment fonctionne la garantie Satisfait ou remboursé ?
q9_answer_html: "Vous pouvez essayer Match Live TV avec votre club pendant 30 jours à compter de l'activation du premier abonnement. Si dans ce délai la plateforme ne convient pas à vos besoins, vous pouvez demander le remboursement de la totalité du montant payé. Pour plus de détails, consultez les %{terms_link}."
q9_terms_link: Conditions générales
q10_question: Puis-je afficher les sponsors du club dans les directs ?
q10_answer: Avec Premium Full, vous pouvez charger une jaquette personnalisée pour le match. Le club prépare son propre graphisme et peut y intégrer sponsors, partenaires, logos et messages. La jaquette saffiche avant le début du direct, donnant de la visibilité aux réalités qui soutiennent le club.
cta_signup: Inscrire l'équipe
cta_live: Voir les directs
volleyball:
+22 -5
View File
@@ -59,11 +59,8 @@ it:
replay_item_download: Download del video sul telefono quando serve
replay_card_title: Archivio partite
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
replay_mock_1_title: Rossi vs Neri
replay_mock_1_meta: "12/05 · 01:25:34"
replay_mock_2_title: Rossi vs Blu
replay_mock_2_meta: "05/05 · 01:18:22"
replay_mock_3_title: Rossi vs Bianchi
replay_mock_3_meta: "28/04 · 01:07:15"
more_title: Anche questo fa parte del prodotto
more_stable_title: Diretta dallo smartphone
@@ -76,6 +73,19 @@ it:
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
cta_primary: Inizia gratis
cta_secondary: Confronta i piani
sponsor_cover:
eyebrow: Premium Full
title: Dai visibilità ai tuoi sponsor
body: Con Premium Full puoi personalizzare la copertina di ogni partita caricando una grafica della tua società. Puoi così inserire sponsor, partner, loghi e comunicazioni e dare loro visibilità già prima dellinizio della diretta.
item_cover: Copertina caricata per ogni partita
item_before: Grafica visibile nel pre-live
item_club: Identità della società, non un template generico
claim: Il tuo club. I tuoi sponsor. La tua diretta.
cta: Scopri Premium Full
mock:
state: PRE-LIVE
art_label: Copertina personalizzata
soon: La diretta inizierà a breve
pricing:
meta_title: "Prezzi streaming partite giovanili — Match Live TV"
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
@@ -88,8 +98,9 @@ it:
table_matches: Partite in contemporanea
table_live_mltv: Live su Match Live TV
table_youtube: YouTube
table_replay: Replay server
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}"
@@ -137,7 +149,7 @@ it:
q3_answer_html: "Sì. Il punteggio a set è pensato per la pallavolo; puoi personalizzare regole per tornei particolari. Scopri la pagina dedicata: %{volleyball_link}."
q3_volleyball_link: Match Live TV per pallavolo giovanile
q4_question: Cosa succede se perdo la diretta?
q4_answer: "Con i piani Premium Light o Full la partita viene salvata in archivio (30 o 90 giorni) e puoi rivederla dal sito. Il piano Free non include replay su server, ma la diretta resta gratuita per chi trasmette e per chi guarda."
q4_answer: "Con i piani Premium Light o Full la partita viene salvata in archivio (30 o 90 giorni) e puoi rivederla dal sito. Il piano Free non include l'archivio replay, ma la diretta resta gratuita per chi trasmette e per chi guarda."
q5_question: Quanto costa per la società?
q5_answer_html: "Puoi iniziare con il piano <strong>Free</strong> (limiti su staff e una diretta alla volta). Premium Light e Full aggiungono più partite in parallelo, archivio e YouTube. %{pricing_link}."
q5_pricing_link: Confronta i prezzi
@@ -147,6 +159,11 @@ it:
q7_answer: "Con Premium Light puoi mandarla sul canale Match Live TV; con Premium Full anche sul canale YouTube della società. Chi preferisce resta sul link Match Live TV, comodo per le famiglie."
q8_question: Devo aprire porte sul router o avere attrezzatura da TV?
q8_answer: "No per chi guarda. Per la società che trasmette basta lo smartphone e una buona connessione in palestra; lo staff tecnico della piattaforma gestisce l'infrastruttura. Niente camere broadcast o mixer."
q9_question: Come funziona la garanzia Soddisfatti o rimborsati?
q9_answer_html: "Puoi provare Match Live TV con la tua società per 30 giorni dall'attivazione del primo abbonamento. Se entro questo periodo ritieni che la piattaforma non sia adatta alle tue esigenze, puoi richiedere il rimborso dell'intero importo pagato. Per maggiori dettagli consulta i %{terms_link}."
q9_terms_link: Termini e Condizioni
q10_question: Posso inserire gli sponsor della società nelle dirette?
q10_answer: Con Premium Full puoi caricare una copertina personalizzata per la partita. La società può preparare la propria grafica inserendo sponsor, partner, loghi e comunicazioni. La copertina viene mostrata prima dellinizio della diretta, permettendo di dare visibilità alle realtà che sostengono il club.
cta_signup: Registra la squadra
cta_live: Guarda le dirette
volleyball:
+13
View File
@@ -18,6 +18,9 @@ de:
logout: Abmelden
account: Konto
signup: Team registrieren
analytics_preview:
badge: Staff-Vorschau — Analytics deaktiviert
manage: Admin
footer:
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
live: Live
@@ -81,6 +84,16 @@ de:
seo_faq_link: FAQ
seo_volleyball_link: Match Live TV für Jugendvolleyball
seo_signup_link: Team kostenlos registrieren
guarantee:
title: Zufrieden oder Geld zurück
kicker_days: 30 Tage
subtitle: 30 Tage, um Match Live TV ohne Risiko zu testen.
body: Wenn Match Live TV nicht die richtige Lösung für Ihren Verein ist, erstatten wir 100 % des Abos.
compact: 30 Tage zufrieden oder Geld zurück
home_note: Zufrieden oder Geld zurück innerhalb von 30 Tagen
checkout: Ihr Kauf ist durch die 30-Tage-Garantie „Zufrieden oder Geld zurück“ abgedeckt.
learn_more: So funktioniert es
aria_pricing: Garantie Zufrieden oder Geld zurück
auth:
email: E-Mail
password: Passwort
+13
View File
@@ -18,6 +18,9 @@ en:
logout: Log out
account: Account
signup: Register a team
analytics_preview:
badge: Staff preview — analytics disabled
manage: Admin
footer:
tagline: Every match, every event, for those who can't be there
live: Live
@@ -81,6 +84,16 @@ en:
seo_faq_link: FAQ
seo_volleyball_link: Match Live TV for youth volleyball
seo_signup_link: register your team for free
guarantee:
title: Satisfied or refunded
kicker_days: 30 days
subtitle: 30 days to try Match Live TV with no risk.
body: If Match Live TV is not the right fit for your club, we refund 100% of the subscription.
compact: 30-day satisfied or refunded
home_note: Satisfied or refunded within 30 days
checkout: Your purchase is covered by the 30-day Satisfied or refunded guarantee.
learn_more: See how it works
aria_pricing: Satisfied or refunded guarantee
auth:
email: Email
password: Password
+13
View File
@@ -18,6 +18,9 @@ es:
logout: Salir
account: Cuenta
signup: Registrar equipo
analytics_preview:
badge: Vista previa staff — analytics desactivados
manage: Admin
footer:
tagline: Cada partido, cada evento, para quien no puede estar
live: Directos
@@ -81,6 +84,16 @@ es:
seo_faq_link: preguntas frecuentes
seo_volleyball_link: Match Live TV para voleibol juvenil
seo_signup_link: registra el equipo gratis
guarantee:
title: Satisfechos o reembolsados
kicker_days: 30 días
subtitle: 30 días para probar Match Live TV sin riesgos.
body: Si Match Live TV no es la solución adecuada para tu club, te reembolsamos el 100% de la suscripción.
compact: 30 días satisfechos o reembolsados
home_note: Satisfechos o reembolsados en 30 días
checkout: Tu compra está cubierta por la garantía Satisfechos o reembolsados de 30 días.
learn_more: Cómo funciona
aria_pricing: Garantía satisfechos o reembolsados
auth:
email: Email
password: Contraseña
+13
View File
@@ -18,6 +18,9 @@ fr:
logout: Déconnexion
account: Compte
signup: Inscrire une équipe
analytics_preview:
badge: Aperçu staff — analytics désactivés
manage: Admin
footer:
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
live: Directs
@@ -81,6 +84,16 @@ fr:
seo_faq_link: FAQ
seo_volleyball_link: Match Live TV pour le volley jeunes
seo_signup_link: inscrivez l'équipe gratuitement
guarantee:
title: Satisfait ou remboursé
kicker_days: 30 jours
subtitle: 30 jours pour essayer Match Live TV sans risque.
body: Si Match Live TV n'est pas la bonne solution pour votre club, nous remboursons 100 % de l'abonnement.
compact: 30 jours satisfait ou remboursé
home_note: Satisfait ou remboursé sous 30 jours
checkout: Votre achat est couvert par la garantie Satisfait ou remboursé de 30 jours.
learn_more: Voir comment ça fonctionne
aria_pricing: Garantie satisfait ou remboursé
auth:
email: E-mail
password: Mot de passe
+13
View File
@@ -18,6 +18,9 @@ it:
logout: Esci
account: Account
signup: Registra squadra
analytics_preview:
badge: Anteprima staff — analytics disattivati
manage: Admin
footer:
tagline: Ogni partita, ogni evento, per chi non può esserci
live: Dirette
@@ -81,6 +84,16 @@ it:
seo_faq_link: domande frequenti
seo_volleyball_link: Match Live TV per la pallavolo giovanile
seo_signup_link: registra la squadra gratis
guarantee:
title: Soddisfatti o rimborsati
kicker_days: 30 giorni
subtitle: 30 giorni per provare Match Live TV senza rischi.
body: Se Match Live TV non è la soluzione giusta per la tua società, ti rimborsiamo il 100% dell'abbonamento.
compact: 30 giorni soddisfatti o rimborsati
home_note: Soddisfatti o rimborsati entro 30 giorni
checkout: Il tuo acquisto è coperto dalla garanzia Soddisfatti o rimborsati di 30 giorni.
learn_more: Scopri come funziona
aria_pricing: Garanzia soddisfatti o rimborsati
auth:
email: Email
password: Password
+5
View File
@@ -121,6 +121,7 @@ Rails.application.routes.draw do
post :regia_link
end
end
resources :stream_concurrency_violations, only: %i[index]
resources :stream_nodes, only: %i[index create destroy] do
member do
post :drain
@@ -130,8 +131,12 @@ Rails.application.routes.draw do
delete :clear_kill_switch
end
end
get "costs", to: "costs#index", as: :costs
resources :cost_entries, path: "costs/entries", except: %i[index show]
get "analytics", to: "analytics#index", as: :analytics
get "analytics/page", to: "analytics#show", as: :analytics_page
post "analytics/preview", to: "analytics#preview_enable", as: :analytics_preview
delete "analytics/preview", to: "analytics#preview_disable"
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
end
@@ -0,0 +1,17 @@
# frozen_string_literal: true
class CreatePlatformCostEntries < ActiveRecord::Migration[7.2]
def change
create_table :platform_cost_entries, id: :uuid do |t|
t.date :month, null: false
t.string :label, null: false, default: "Piattaforma produzione"
t.integer :amount_cents, null: false
t.text :notes
t.timestamps
end
add_index :platform_cost_entries, :month
add_index :platform_cost_entries, %i[month label]
end
end
@@ -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
+47 -2
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) 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"
@@ -313,6 +313,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
t.index ["slug"], name: "index_plans_on_slug", unique: true
end
create_table "platform_cost_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.date "month", null: false
t.string "label", default: "Piattaforma produzione", null: false
t.integer "amount_cents", null: false
t.text "notes"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["month", "label"], name: "index_platform_cost_entries_on_month_and_label"
t.index ["month"], name: "index_platform_cost_entries_on_month"
end
create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "stream_session_id", null: false
t.uuid "team_id", null: false
@@ -372,6 +383,34 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) 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
@@ -429,9 +468,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) 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"
@@ -447,6 +486,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) 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
@@ -580,6 +620,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
add_foreign_key "recordings", "stream_sessions"
add_foreign_key "recordings", "teams"
add_foreign_key "score_states", "stream_sessions"
add_foreign_key "stream_concurrency_violations", "clubs", column: "attempted_club_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "clubs", column: "occupying_club_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "stream_sessions", column: "attempted_session_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "stream_sessions", column: "occupying_session_id", on_delete: :nullify
add_foreign_key "stream_concurrency_violations", "users", on_delete: :nullify
add_foreign_key "stream_events", "stream_sessions"
add_foreign_key "stream_sessions", "matches"
add_foreign_key "stream_sessions", "stream_nodes"
+5 -5
View File
@@ -16,7 +16,7 @@ admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
u.role = "admin"
end
club = Club.find_or_create_by!(name: "Tigers Volley") do |c|
club = Club.find_or_create_by!(name: MatchLiveTv::Demo.home_team) do |c|
c.sport = "volleyball"
c.primary_color = "#e53935"
c.secondary_color = "#ffffff"
@@ -24,7 +24,7 @@ end
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
team = club.teams.find_or_create_by!(name: "Under 16") do |t|
team = club.teams.find_or_create_by!(name: MatchLiveTv::Demo::CATEGORY) do |t|
t.sport = "volleyball"
end
@@ -32,12 +32,12 @@ UserTeam.find_or_create_by!(user: admin, team: team) { |ut| ut.role = "member" }
Billing::AssignPlan.call(club: club, plan_slug: "free") unless club.subscription
match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m|
m.location = "PalaTigers - Milano"
match = team.matches.find_or_create_by!(opponent_name: MatchLiveTv::Demo::AWAY_TEAM) do |m|
m.location = MatchLiveTv::Demo.venue
m.scheduled_at = 2.hours.from_now
m.sets_to_win = 3
m.roster_numbers = [4, 6, 8, 9, 10, 11, 12, 14, 16, 17, 21]
m.category = "U16"
m.category = MatchLiveTv::Demo::CATEGORY
m.phase = "Semifinale"
end
+132
View File
@@ -0,0 +1,132 @@
# frozen_string_literal: true
# Dati simulati per la pagina Admin → Costi (trend 12 mesi, società, revenue).
# Uso: bundle exec rails runner db/seeds/cost_analytics_demo.rb
load Rails.root.join("db/seeds/plans.rb")
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
u.name = "Coach Demo"
u.password = "Password123"
u.role = "coach"
end
clubs_data = [
{ 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 = {
"volleyball" => "pallavolo",
"basketball" => "basket"
}
clubs = clubs_data.map do |row|
club = Club.find_or_create_by!(name: row[:name]) { |c| c.sport = row[:sport] }
Billing::AssignPlan.call(club: club, plan_slug: row[:plan])
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
team = club.teams.find_or_create_by!(name: "Prima squadra") do |t|
t.sport_key = sport_keys.fetch(row[:sport], "pallavolo")
end
{ club: club, team: team, **row }
end
month_costs = [
{ offset: 11, hetzner: 89.00, stream: 12.50, s3: 8.20 },
{ offset: 10, hetzner: 89.00, stream: 18.00, s3: 9.10 },
{ offset: 9, hetzner: 89.00, stream: 24.50, s3: 10.40 },
{ offset: 8, hetzner: 89.00, stream: 31.00, s3: 11.80 },
{ offset: 7, hetzner: 89.00, stream: 22.00, s3: 12.20 },
{ offset: 6, hetzner: 89.00, stream: 15.50, s3: 13.50 },
{ offset: 5, hetzner: 89.00, stream: 28.00, s3: 14.80 },
{ offset: 4, hetzner: 89.00, stream: 35.50, s3: 15.60 },
{ offset: 3, hetzner: 89.00, stream: 42.00, s3: 16.90 },
{ offset: 2, hetzner: 89.00, stream: 38.00, s3: 18.20 },
{ offset: 1, hetzner: 89.00, stream: 45.00, s3: 19.50 },
{ offset: 0, hetzner: 89.00, stream: 52.00, s3: 21.00 }
]
PlatformCostEntry.delete_all
month_costs.each do |row|
month = Time.zone.today.beginning_of_month - row[:offset].months
[
{ label: "Hetzner home + backup", amount: row[:hetzner] },
{ label: "Nodi stream CPX (overflow)", amount: row[:stream] },
{ label: "Garage/S3 replay storage", amount: row[:s3] }
].each do |entry|
PlatformCostEntry.create!(
month: month,
label: entry[:label],
amount_cents: (entry[:amount] * 100).round,
notes: "Dato demo seed"
)
end
end
# Sessioni e pagamenti nel mese corrente (distribuiti tra società)
current_month = Time.zone.today.beginning_of_month
clubs.each_with_index do |row, index|
club = row[:club]
team = row[:team]
sessions_count = row[:sessions]
total_secs = (row[:hours] * 3600).round
sessions_count.times do |n|
secs = (total_secs / sessions_count.to_f).round
day = current_month + (n % 27).days
started = day.change(hour: 10 + (n % 6), min: 0)
ended = started + secs.seconds
match = team.matches.create!(
opponent_name: MatchLiveTv::Demo::AWAY_TEAM,
sport_key: team.sport_key,
scheduled_at: started
)
StreamSession.create!(
match: match,
user: coach,
platform: "matchlivetv",
status: "ended",
started_at: started,
ended_at: ended,
total_duration_secs: secs
)
end
if row[:revenue_yearly].positive?
payment = Billing::Payment.find_or_initialize_by(club: club, paid_at: current_month + (index + 3).days)
payment.assign_attributes(
status: "paid",
provider: "stripe",
amount_cents: row[:revenue_yearly],
currency: "EUR",
plan_slug: row[:plan]
)
payment.save!
end
# Storage simulato (cumulativo)
sample_session = team.matches.last&.stream_sessions&.first
next unless sample_session
rec = Recording.find_or_initialize_by(stream_session: sample_session)
rec.assign_attributes(
team: team,
status: "ready",
privacy_status: "unlisted",
storage_backend: "s3",
storage_policy: "retained",
byte_size: (500_000_000 + index * 180_000_000),
duration_secs: 3600
)
rec.save!
end
puts "Cost analytics demo OK:"
puts " Voci costo: #{PlatformCostEntry.count} (12 mesi)"
puts " Sessioni demo mese corrente: #{StreamSession.where(status: 'ended', ended_at: current_month..current_month.end_of_month).count}"
puts " Admin: http://localhost:3000/admin/costs"
puts " Login admin: admin / AdminPass123"
+42
View File
@@ -0,0 +1,42 @@
# frozen_string_literal: true
module MatchLiveTv
# Set demo ufficiale per mockup, preview e contenuti dimostrativi del sito.
# Nomi fittizi, chiaramente riconducibili a MatchLiveTV — mai società o sponsor reali.
module Demo
HOME_TEAM = "Team MLTV"
AWAY_TEAM = "Team Guest"
CATEGORY = "U15 MASCHILE"
VENUE = "Pala MLTV"
module_function
def home_team
HOME_TEAM
end
def away_team
I18n.t("demo.away_team", default: AWAY_TEAM)
end
def category
CATEGORY
end
def when_label
I18n.t("demo.when")
end
def venue
VENUE
end
def match_title
"#{home_team} vs #{away_team}"
end
def live_meta
"#{VENUE} · Match Live TV"
end
end
end
+132
View File
@@ -0,0 +1,132 @@
(function () {
var trend = window.adminCostTrend || [];
var i18n = window.adminCostI18n || {};
if (!trend.length || typeof Chart === "undefined") return;
function monthLabel(monthStr) {
var parts = monthStr.split("-");
if (parts.length < 2) return monthStr;
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1);
return d.toLocaleDateString([], { month: "short", year: "2-digit" });
}
var labels = trend.map(function (row) {
return monthLabel(row.month);
});
var chartOptions = {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 300 },
scales: {
x: {
ticks: { maxTicksLimit: 12, color: "#9a9aad", font: { size: 10 } },
grid: { color: "rgba(255,255,255,0.06)" }
},
y: {
beginAtZero: true,
ticks: { color: "#9a9aad", font: { size: 10 } },
grid: { color: "rgba(255,255,255,0.06)" }
}
},
plugins: { legend: { labels: { color: "#ccc", boxWidth: 12 } } }
};
var euroTicks = {
ticks: {
color: "#9a9aad",
font: { size: 10 },
callback: function (v) {
return "€" + v;
}
}
};
var costRevenueCanvas = document.getElementById("chart-cost-revenue");
if (costRevenueCanvas) {
new Chart(costRevenueCanvas, {
type: "bar",
data: {
labels: labels,
datasets: [
{
label: i18n.cost || "Cost",
data: trend.map(function (r) { return (r.cost_cents || 0) / 100; }),
backgroundColor: "rgba(229, 57, 53, 0.65)"
},
{
label: i18n.revenue || "Revenue",
data: trend.map(function (r) { return (r.revenue_cents || 0) / 100; }),
backgroundColor: "rgba(67, 160, 71, 0.65)"
},
{
label: i18n.margin || "Margin",
data: trend.map(function (r) { return (r.margin_cents || 0) / 100; }),
backgroundColor: "rgba(255, 193, 7, 0.55)"
}
]
},
options: {
...chartOptions,
scales: {
x: chartOptions.scales.x,
y: { ...chartOptions.scales.y, ...euroTicks }
}
}
});
}
var costHourCanvas = document.getElementById("chart-cost-hour");
if (costHourCanvas) {
new Chart(costHourCanvas, {
type: "line",
data: {
labels: labels,
datasets: [{
label: i18n.costPerHour || "Cost/hour",
data: trend.map(function (r) {
return r.cost_per_hour_cents ? r.cost_per_hour_cents / 100 : 0;
}),
borderColor: "#e53935",
backgroundColor: "rgba(229, 57, 53, 0.12)",
fill: true,
tension: 0.35,
pointRadius: 2,
borderWidth: 2
}]
},
options: {
...chartOptions,
scales: {
x: chartOptions.scales.x,
y: { ...chartOptions.scales.y, ...euroTicks }
},
plugins: { legend: { display: false } }
}
});
}
var hoursCanvas = document.getElementById("chart-hours");
if (hoursCanvas) {
new Chart(hoursCanvas, {
type: "line",
data: {
labels: labels,
datasets: [{
label: i18n.hours || "Hours",
data: trend.map(function (r) { return r.hours || 0; }),
borderColor: "#43a047",
backgroundColor: "rgba(67, 160, 71, 0.12)",
fill: true,
tension: 0.35,
pointRadius: 2,
borderWidth: 2
}]
},
options: {
...chartOptions,
plugins: { legend: { display: false } }
}
});
}
})();
+18
View File
@@ -236,6 +236,11 @@ body.admin-body {
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
.badge--ended { background: #37474f; color: #eceff1; }
.badge--error { background: #b71c1c; color: #ffebee; }
.badge--abuse { background: #b71c1c; color: #ffebee; }
.admin-table tr.admin-row--two-devices td {
background: rgba(183, 28, 28, 0.12);
}
.admin-ingest {
display: inline-flex;
@@ -693,6 +698,13 @@ body.admin-body {
background: #3a1515;
}
.admin-device-tab-count {
margin-left: 0.35rem;
padding: 0 0.35rem;
font-size: 0.75rem;
color: var(--muted);
}
.admin-locale-panel {
display: grid;
gap: 0.75rem;
@@ -793,6 +805,12 @@ body.admin-body {
gap: 0.6rem;
}
.admin-analytics-preview__status {
margin: 0 0 0.75rem;
color: #a5f0b8;
font-size: 0.9rem;
}
.admin-filter-count {
font-size: 0.85rem;
}
+10
View File
@@ -37,6 +37,11 @@
return document.body && document.body.getAttribute("data-ga-id");
}
function isAnalyticsSuppressed() {
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
return meta && meta.getAttribute("content") === "1";
}
function loadGoogleAnalytics() {
var id = measurementId();
if (!id || window.__mltvGaLoaded) return;
@@ -68,6 +73,7 @@
}
function applyConsent(consent) {
if (isAnalyticsSuppressed()) return;
if (consent && consent.analytics) {
loadGoogleAnalytics();
if (typeof window.mltvSiteAnalyticsStart === "function") {
@@ -94,6 +100,10 @@
if (acceptAll) {
acceptAll.addEventListener("click", function () {
if (isAnalyticsSuppressed()) {
hideBanner(banner);
return;
}
var c = writeConsent({ analytics: true });
applyConsent(c);
hideBanner(banner);
+459 -1
View File
@@ -1623,9 +1623,14 @@ body.nav-menu-open { overflow: hidden; }
}
.plan-card > .btn,
.plan-card > .plan-action-hint,
.plan-card > .plan-interval-actions {
.plan-card > .plan-interval-actions,
.plan-card__cta {
margin-top: auto;
}
.plan-card__cta .plan-interval-actions:first-child,
.plan-card__cta > .btn:first-child {
margin-top: 0;
}
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
.feature-card {
display: flex;
@@ -2458,6 +2463,162 @@ body.nav-menu-open { overflow: hidden; }
font-size: 0.85rem;
}
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
.refund-guarantee--pricing {
display: grid;
grid-template-columns: auto 1fr;
gap: 18px 20px;
align-items: start;
margin-top: 24px;
padding: 22px 24px;
background:
linear-gradient(180deg, rgba(229, 57, 53, 0.07), transparent 42%),
#14141c;
border: 1px solid #2a2a36;
border-radius: 14px;
box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.14);
}
.refund-guarantee__icon {
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(229, 57, 53, 0.12);
color: #e53935;
font-size: 1.15rem;
line-height: 1;
flex-shrink: 0;
}
.refund-guarantee__copy { min-width: 0; }
.refund-guarantee__title {
margin: 0 0 6px;
color: #f0f0f4;
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.01em;
line-height: 1.3;
}
.refund-guarantee__kicker {
display: none;
font-weight: 600;
color: #c8c8d0;
}
.refund-guarantee__subtitle {
margin: 0 0 8px;
color: #ddd;
font-size: 0.92rem;
font-weight: 600;
line-height: 1.4;
}
.refund-guarantee__body {
margin: 0;
color: #aaa;
font-size: 0.9rem;
line-height: 1.55;
max-width: 46rem;
}
.refund-guarantee__more {
margin: 10px 0 0;
font-size: 0.85rem;
}
.refund-guarantee__more a {
color: #ff8a80;
text-decoration: none;
font-weight: 600;
}
.refund-guarantee__more a:hover {
color: #ff6e63;
text-decoration: underline;
}
.refund-guarantee--compact {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 12px 0 0;
padding: 10px 4px 0;
border-top: 1px solid #2a2a36;
color: #c8c8d0;
font-size: 0.8rem;
font-weight: 600;
line-height: 1.35;
text-align: center;
}
.refund-guarantee--compact i {
color: #e53935;
font-size: 0.88rem;
flex-shrink: 0;
}
.refund-guarantee--cta {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
margin: 14px 0 0;
color: #e8e8ee;
font-size: 0.95rem;
font-weight: 600;
line-height: 1.4;
letter-spacing: 0.01em;
}
.refund-guarantee--cta i {
color: #e53935;
font-size: 0.95rem;
flex-shrink: 0;
}
.refund-guarantee--cta span {
white-space: nowrap;
}
.refund-guarantee--checkout {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 12px 0 16px;
padding: 14px 16px;
background: #14141c;
border: 1px solid #2a2a36;
border-left: 3px solid #e53935;
border-radius: 10px;
color: #aaa;
font-size: 0.9rem;
line-height: 1.5;
}
.refund-guarantee--checkout i {
color: #e53935;
font-size: 1.05rem;
margin-top: 2px;
flex-shrink: 0;
}
.refund-guarantee--checkout p { margin: 0; }
@media (max-width: 640px) {
.refund-guarantee--pricing {
grid-template-columns: 1fr;
gap: 12px;
padding: 16px;
}
.refund-guarantee--pricing .refund-guarantee__icon {
width: 36px;
height: 36px;
font-size: 0.95rem;
border-radius: 10px;
}
.refund-guarantee__kicker { display: inline; }
.refund-guarantee--pricing .refund-guarantee__subtitle { display: none; }
.refund-guarantee__title { font-size: 0.98rem; }
.refund-guarantee__body { font-size: 0.86rem; }
.refund-guarantee--compact {
font-size: 0.76rem;
padding-top: 8px;
margin-top: 10px;
}
.refund-guarantee--cta {
font-size: 0.88rem;
gap: 6px;
margin-top: 12px;
}
}
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; }
.site-footer__brand {
@@ -2627,6 +2788,7 @@ body.nav-menu-open { overflow: hidden; }
border-radius: 12px;
margin-bottom: 10px;
padding: 0 18px;
scroll-margin-top: 88px;
}
.faq-item summary {
cursor: pointer;
@@ -2646,6 +2808,7 @@ body.nav-menu-open { overflow: hidden; }
.faq-item p { color: #aaa; margin: 0 0 16px; line-height: 1.55; font-size: 0.95rem; }
.legal-doc { max-width: 760px; padding-top: 24px; padding-bottom: 48px; color: #bbb; line-height: 1.65; font-size: 0.95rem; }
.legal-doc section { scroll-margin-top: 88px; }
.legal-doc h1 { color: #fff; font-size: 1.6rem; margin-bottom: 8px; }
.legal-doc h2 { color: #fff; font-size: 1.1rem; margin: 28px 0 10px; }
.legal-doc h3 { color: #ddd; font-size: 1rem; margin: 20px 0 8px; }
@@ -3549,3 +3712,298 @@ a.replay-archive__thumb:hover {
grid-template-columns: 1fr;
}
}
.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;
left: 16px;
z-index: 1200;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-radius: 8px;
background: rgba(20, 20, 28, 0.94);
border: 1px solid #444;
color: #ddd;
font-size: 0.82rem;
line-height: 1.3;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.mltv-preview-badge__link {
color: #ff8a80;
text-decoration: none;
font-weight: 600;
}
.mltv-preview-badge__link:hover {
color: #ff5252;
}
+32 -2
View File
@@ -29,6 +29,11 @@
return false;
}
function isAnalyticsSuppressed() {
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
return meta && meta.getAttribute("content") === "1";
}
function hasAnalyticsConsent() {
try {
var raw = localStorage.getItem(STORAGE_KEY);
@@ -104,7 +109,7 @@
function flush(useBeacon) {
flushMovesIntoQueue();
if (!queue.length || !hasAnalyticsConsent()) {
if (!queue.length || !hasAnalyticsConsent() || isAnalyticsSuppressed()) {
queue = [];
return;
}
@@ -236,7 +241,7 @@
}
function captureSnapshot() {
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
if (!hasAnalyticsConsent() || isAnalyticsSuppressed() || alreadyCapturedToday()) return;
loadHtml2Canvas(function (html2canvas) {
var size = pageSize();
html2canvas(document.documentElement, {
@@ -278,9 +283,31 @@
});
}
function trackNamedEvent(name) {
if (!name) return;
if (typeof window.gtag === "function") {
window.gtag("event", name, { event_category: "engagement" });
}
}
function onNamedEventClick(event) {
var target = event.target;
if (!(target instanceof Element)) return;
var el = target.closest("[data-mltv-event-click]");
if (!el) return;
trackNamedEvent(el.getAttribute("data-mltv-event-click"));
}
function onDetailsToggle(event) {
var el = event.target;
if (!(el instanceof HTMLDetailsElement) || !el.open) return;
trackNamedEvent(el.getAttribute("data-mltv-event"));
}
function start() {
if (started) return;
if (excludedPath(location.pathname)) return;
if (isAnalyticsSuppressed()) return;
if (!hasAnalyticsConsent()) return;
started = true;
maxScroll = scrollPct();
@@ -304,6 +331,9 @@
window.mltvSiteAnalyticsStart = start;
document.addEventListener("click", onNamedEventClick, true);
document.addEventListener("toggle", onDetailsToggle, true);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", start);
} else {
@@ -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
+22
View File
@@ -109,6 +109,28 @@ RSpec.describe "Account API", type: :request do
expect(user.reload.password_reset_digest).to be_present
end
it "non va in 500 se in produzione manca SMTP" do
allow(MatchLiveTv).to receive(:smtp_configured?).and_return(false)
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
expect {
post "/api/v1/auth/password/forgot", params: { email: user.email }
}.not_to change { ActionMailer::Base.deliveries.size }
expect(response).to have_http_status(:ok)
expect(user.reload.password_reset_digest).to be_present
end
it "non va in 500 se SMTP rifiuta il destinatario" do
allow_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_now)
.and_raise(Net::SMTPFatalError.new("556 5.1.10 invalid destination domain"))
expect {
post "/api/v1/auth/password/forgot", params: { email: user.email }
}.not_to raise_error
expect(response).to have_http_status(:ok)
expect(user.reload.password_reset_digest).to be_present
end
it "returns the same message for unknown emails" do
expect {
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
+31 -1
View File
@@ -35,10 +35,40 @@ RSpec.describe "Admin analytics", type: :request do
expect(response.body).to include("40")
end
it "mostra la heatmap di una pagina" do
it "mostra la heatmap di una pagina con tab dispositivo" do
get admin_analytics_page_path, params: { page_path: "/prezzi" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("admin-heatmap")
expect(response.body).to include("/prezzi")
expect(response.body).to include("is-active")
expect(response.body).to include(I18n.t("admin.analytics.devices.desktop"))
end
it "mostra solo i punti del dispositivo selezionato" do
AnalyticsPageCell.create!(
day: Time.zone.today,
page_path: "/prezzi",
device: "mobile",
cell_x: 5,
cell_y: 8,
click_count: 9,
move_count: 0
)
get admin_analytics_page_path, params: { page_path: "/prezzi", device: "mobile", layer: "click" }
expect(response).to have_http_status(:ok)
expect(response.body).to include("x&quot;:5")
expect(response.body).to include("c&quot;:9")
expect(response.body).not_to include("x&quot;:10")
end
it "attiva e disattiva l'anteprima staff" do
post admin_analytics_preview_path, params: { return_to: "/" }
expect(response).to redirect_to("/")
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to eq("1")
delete admin_analytics_preview_path
expect(response).to redirect_to(admin_analytics_path)
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to be_nil
end
end
+44
View File
@@ -0,0 +1,44 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Admin costs", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-costs", password: "Password123") }
let(:month) { Time.zone.today.beginning_of_month }
before do
post admin_login_path, params: { username: admin.username, password: "Password123" }
PlatformCostEntry.create!(month: month, label: "Piattaforma", amount_cents: 8_500)
end
it "mostra la dashboard costi" do
get admin_costs_path, params: { month: month.strftime("%Y-%m") }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.costs.index.title"))
expect(response.body).to include("85.00")
end
it "crea, aggiorna ed elimina una voce di costo" do
post admin_cost_entries_path,
params: {
platform_cost_entry: {
month: month.strftime("%Y-%m"),
label: "Stream CPX",
amount_euros: "42.50",
notes: "overflow nodes"
}
}
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
entry = PlatformCostEntry.find_by(label: "Stream CPX")
expect(entry.amount_cents).to eq(4_250)
patch admin_cost_entry_path(entry),
params: { platform_cost_entry: { month: month.strftime("%Y-%m"), label: "Stream CPX", amount_euros: "50" } }
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
expect(entry.reload.amount_cents).to eq(5_000)
delete admin_cost_entry_path(entry)
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
expect(PlatformCostEntry.find_by(id: entry.id)).to be_nil
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
@@ -52,6 +52,24 @@ RSpec.describe "Analytics ingest", type: :request do
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
end
it "ignora eventi con cookie anteprima staff" do
expect do
post "/analytics/events",
params: {
events: [
{ type: "pageview", path: "/prezzi-suppress", device: "desktop", ts: Time.current.to_i * 1000 }
]
},
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" },
as: :json
end.not_to change { AnalyticsPageStat.where(page_path: "/prezzi-suppress").count }
expect(response).to have_http_status(:accepted)
body = JSON.parse(response.body)
expect(body["accepted"]).to eq(0)
expect(body["suppressed"]).to eq(true)
end
it "rifiuta path esclusi" do
post "/analytics/events",
params: {
@@ -29,6 +29,7 @@ RSpec.describe "Analytics snapshots", type: :request do
end
it "rifiuta path esclusi" do
expect do
post "/analytics/snapshot",
params: {
path: "/admin/analytics",
@@ -37,8 +38,27 @@ RSpec.describe "Analytics snapshots", type: :request do
height: 900,
image: jpeg_upload
}
end.not_to change(AnalyticsPageSnapshot, :count)
expect(response).to have_http_status(:unprocessable_content)
expect(AnalyticsPageSnapshot.count).to eq(0)
end
it "salta snapshot con cookie anteprima staff" do
expect do
post "/analytics/snapshot",
params: {
path: "/prezzi-suppress",
device: "desktop",
width: 1440,
height: 2200,
image: jpeg_upload
},
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" }
end.not_to change(AnalyticsPageSnapshot, :count)
expect(response).to have_http_status(:accepted)
body = JSON.parse(response.body)
expect(body["skipped"]).to eq(true)
expect(body["suppressed"]).to eq(true)
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

Some files were not shown because too many files have changed in this diff Show More