Compare commits

...
7 Commits
Author SHA1 Message Date
eminuxandCursor ce81bb5789 Nasconde dall'hub le partite con risultato già inserito.
Restano avviabili solo le gare in ritardo senza punteggio, non quelle già terminate dal portale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:13:10 +02:00
eminuxandCursor 24c6ce4da0 Lascia trasmissibili le partite anche dopo l'orario previsto.
Se l'operatore è in ritardo deve ancora trovare la gara nell'app: restano in hub per tutta la giornata e, nei tornei aperti, fino alla fine dell'evento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:07:23 +02:00
eminuxandCursor c3ef8e91a6 Rende usabili calendario, overlay ospite e stato diretta del cartellone.
Il calendario non deve più scorrere in orizzontale, il logo a destra non copre il nome e una diretta YouTube o in collegamento compare come In diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:55:08 +02:00
eminuxandCursor b09e83db09 Allinea stato e titolo replay del tabellone al risultato reale.
Una gara con punteggio non resta più «In programma» solo perché l'orario è futuro, e i replay usano le squadre del match invece della squadra di trasmissione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:15:10 +02:00
eminux 097e55df79 Merge branch 'feature/tornei' into collaudo 2026-09-05 15:06:24 +02:00
eminuxandCursor a1f4c24a43 Aggiunge i tornei con hub, pagina pubblica e tabellone per semifinali e finali.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 15:05:57 +02:00
eminuxandCursor 7c7b2bf14c Alza soft capacity fase A e aggiunge quiet hours CPX notturne.
Home a 4 e cloud a 6 con MAX_NODES=12 (~76 soft); di notte (02–07 Europe/Rome) niente scale-out/warm-spare e sweeper che chiude i CPX idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:48:50 +02:00
139 changed files with 7425 additions and 124 deletions
+11 -7
View File
@@ -9,14 +9,18 @@ Con la crescita del numero di clienti (e delle dirette concorrenti attese), **al
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
| Parametro | Ruolo oggi (post load test 2026-08) |
|-----------|--------------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
| Parametro | Ruolo (qualità-first 2026-09) |
|-----------|-------------------------------|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (**4** — scale-out anticipato) |
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (**6**; 8 solo dopo misure QoS) |
| `STREAM_AUTOSCALE_MAX_NODES` | Quanti CPX in parallelo (fase A **12** → B 22 → C 33) |
| `STREAM_AUTOSCALE_SOFT_FREE_SLOTS` | Anticipo scale-out |
| `STREAM_AUTOSCALE_MONTHLY_BUDGET_EUR` | Gate 24/7 (fase A **150**) |
| `STREAM_AUTOSCALE_QUIET_HOURS` | `02:00-07:00` Europe/Rome: no scale-out/warm-spare; sweeper chiude CPX idle |
| Piano `concurrent_streams_limit` | Tetto **per club** (Premium Full = 10): indipendente dal cluster |
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (es. 6+3×4 = **18**).
Capienza cluster soft ≈ `home_max + max_overflow × cloud_max` (fase A: 4+12×6 = **76**; fase C: 4+33×6 = **202**).
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo.
Quiet hours: `Streams::NightCloudSweeper` + blocco provision in `Streams::Autoscaler`. Nodi cloud con sessioni attive di notte → alert Ops, non spegnere.
Quando si parla di capacity planning, deploy autoscale, o “troppe dirette”, ricordare di rivedere questi valori (e il limite piano) in base ai clienti reali — non lasciare i soft limit di collaudo/early-prod a lungo. Densità 8 solo dopo misure.
@@ -9,34 +9,23 @@ module Api
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
end
render json: {
valid: true,
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
render json: invitation_json(invitation).merge(valid: true)
end
def accept
invitation = find_pending_invitation
return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation
if current_user.email.downcase != invitation.email.downcase
email = invitation.email
if current_user.email.downcase != email.downcase
return render json: {
error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.",
invited_email: invitation.email
error: "Questo invito è per #{email}. Accedi con quell'indirizzo email.",
invited_email: email
}, status: :unprocessable_entity
end
invitation.accept!(current_user)
render json: {
team_id: invitation.team_id,
team_name: invitation.team.name,
message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione."
}
render json: invitation_json(invitation).merge(message: accept_message(invitation))
end
private
@@ -45,7 +34,42 @@ module Api
token = params[:token].to_s
return nil if token.blank?
TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
digest = Digest::SHA256.hexdigest(token)
TeamInvitation.pending.find_by(token_digest: digest) ||
TournamentBroadcastInvitation.pending.find_by(token_digest: digest)
end
def invitation_json(invitation)
if invitation.is_a?(TournamentBroadcastInvitation)
tournament = invitation.tournament
{
email: invitation.email,
tournament_id: tournament.id,
tournament_name: tournament.name,
club_name: tournament.club.name,
team_id: tournament.broadcast_team_id,
team_name: tournament.name,
staff_kind: "transmission",
expires_at: invitation.expires_at
}
else
{
email: invitation.email,
team_id: invitation.team_id,
team_name: invitation.team.name,
club_name: invitation.team.club.name,
staff_kind: invitation.staff_kind,
expires_at: invitation.expires_at
}
end
end
def accept_message(invitation)
if invitation.is_a?(TournamentBroadcastInvitation)
"Sei incaricato delle dirette per #{invitation.tournament.name}."
else
"Sei entrato in #{invitation.team.name} come responsabile trasmissione."
end
end
end
end
@@ -10,13 +10,22 @@ module Api
def index
matches = @team.matches
.includes(:team, :stream_sessions)
.includes(:team, :stream_sessions, :home_participant, :away_participant, :tournament)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
.select(&:coach_hub_visible?)
render json: matches.map { |m| match_json(m) }
unless current_user.club_admin?(@team.club)
if @team.tournament_broadcast?
assigned_ids = current_user.tournament_broadcast_assignments.select(:match_id)
matches = matches.where(id: assigned_ids)
end
end
render json: matches.select(&:coach_hub_visible?).map { |m| match_json(m) }
end
def create
if @team.tournament_broadcast?
return render json: { error: "Le partite del torneo si programmano dal sito web." }, status: :unprocessable_entity
end
attrs = match_params.to_h
attrs["sport_key"] = @team.sport_key
normalize_scoring_rules!(attrs)
@@ -112,9 +121,12 @@ module Api
{
id: match.id,
team_id: match.team_id,
team_name: team.name,
opponent_name: match.opponent_name,
location: match.location,
team_name: match.home_display_name,
opponent_name: match.away_display_name,
location: match.court_or_location,
court: match.court,
tournament_id: match.tournament_id,
tournament_name: match.tournament&.name,
scheduled_at: match.scheduled_at,
sport: match.sport_key,
sport_key: match.sport_key,
@@ -126,11 +138,11 @@ module Api
scoring_rules: match.scoring_rules.presence,
effective_scoring_rules: match.effective_scoring_rules,
category: match.category,
home_primary_color: team.effective_primary_color,
home_secondary_color: team.effective_secondary_color,
home_logo_url: api_absolute_url(team.effective_logo_url),
opponent_primary_color: match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
home_primary_color: match.home_participant&.effective_primary_color || team.effective_primary_color,
home_secondary_color: match.home_participant&.effective_secondary_color || team.effective_secondary_color,
home_logo_url: api_absolute_url(match.home_participant&.effective_logo_url || team.effective_logo_url),
opponent_primary_color: match.away_participant&.effective_primary_color || match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.away_participant&.effective_logo_url || match.opponent_logo_url),
**match_cover_json(match),
active_session_id: active&.id,
active_session_status: active&.status,
@@ -6,6 +6,10 @@ module Api
def create
team_ids = current_user.streamable_teams.map(&:id)
match = Match.where(team_id: team_ids).find(params[:match_id])
unless current_user.can_broadcast_match?(match)
return render json: { error: "Non sei incaricato di trasmettere questa partita" }, status: :forbidden
end
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
render json: session_json(session), status: :created
end
@@ -110,6 +110,9 @@ module Api
secondary_color: team.effective_secondary_color,
club_id: team.club_id,
club_name: team.club.name,
tournament_broadcast: team.tournament_broadcast?,
tournament_id: team.broadcast_tournament&.id,
tournament_name: team.broadcast_tournament&.name,
youtube_connected: yt.connected?,
youtube_selectable: yt.selectable?,
youtube_channel_title: yt.channel_title,
@@ -56,9 +56,9 @@ module Public
def show
require_club_owner!(@club)
apply_checkout_flash!
@entitlements_team = @club.teams.first
@entitlements_team = @club.teams.visible.first
@entitlements = @entitlements_team&.entitlements
@teams = @club.teams.order(:name)
@teams = @club.teams.visible.order(:name)
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
end
@@ -2,26 +2,40 @@ module Public
class InvitationsController < WebBaseController
def show
@token = params[:token]
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
unless @invitation
digest = Digest::SHA256.hexdigest(@token.to_s)
@invitation = TeamInvitation.pending.find_by(token_digest: digest)
@tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless @invitation
unless @invitation || @tournament_invitation
redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired")
end
end
def accept
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation
token = params[:token].to_s
digest = Digest::SHA256.hexdigest(token)
invitation = TeamInvitation.pending.find_by(token_digest: digest)
tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless invitation
if logged_in?
if current_user.email.downcase != invitation.email.downcase
redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email)
return
unless invitation || tournament_invitation
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid")
end
target_email = (invitation || tournament_invitation).email
unless logged_in?
session[:pending_invite_token] = token
return redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: target_email)
end
if current_user.email.downcase != target_email.downcase
return redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: target_email)
end
if invitation
invitation.accept!(current_user)
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
else
session[:pending_invite_token] = params[:token]
redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email)
tournament_invitation.accept!(current_user)
redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
end
end
end
@@ -33,7 +33,11 @@ module Public
.order(scheduled_at: :asc)
.limit(50)
@online_paths = Mediamtx::Client.new.online_path_names
tournaments = Tournament.listed_on_live.with_attached_logo_file.includes(:club).search_public(@query)
tournaments = tournaments.where(club_id: @club.id) if @club
@public_tournaments = tournaments.order(starts_on: :desc, name: :asc).limit(12)
@online_paths = fetch_online_paths
return unless logged_in? && @club
@@ -83,5 +87,13 @@ module Public
score: session.score_state&.as_cable_payload
}
end
private
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -17,11 +17,18 @@ module Public
session[:user_id] = @user.id
if session[:pending_invite_token].present?
token = session.delete(:pending_invite_token)
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
digest = Digest::SHA256.hexdigest(token)
invitation = TeamInvitation.pending.find_by(token_digest: digest)
if invitation && invitation.email.downcase == @user.email.downcase
invitation.accept!(@user)
return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
end
tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest)
if tournament_invitation && tournament_invitation.email.downcase == @user.email.downcase
tournament_invitation.accept!(@user)
return redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
end
end
redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
else
@@ -45,6 +45,8 @@ module Public
PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members
tournaments tournament_participants tournament_groups
tournament_matches tournament_invitations tournament_results
].freeze
def load_site_announcements
@@ -11,6 +11,7 @@ module Public
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/tornei", changefreq: "daily", priority: "0.8" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
@@ -25,6 +26,14 @@ module Public
}
end
Tournament.visible_to_public.find_each do |tournament|
@entries << {
loc: "#{base}/tornei/#{tournament.slug}",
changefreq: "daily",
priority: "0.75"
}
end
respond_to do |format|
format.xml { render layout: false }
end
@@ -27,6 +27,11 @@ module Public
end
def details
if @team.tournament_broadcast? && @team.broadcast_tournament
redirect_to public_tournament_path(@team.broadcast_tournament)
return
end
load_team_details!
render :details
end
@@ -0,0 +1,29 @@
module Public
class TournamentAssignmentsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
unless @tournament.writable?
redirect_to public_tournament_path(@tournament, tab: "dirette"),
alert: t("flash.tournaments.archived_locked")
return
end
assignment = @tournament.broadcast_assignments.find(params[:assignment_id])
Tournaments::RevokeAssignment.call(assignment: assignment)
redirect_to public_tournament_path(@tournament, tab: "dirette"),
notice: t("flash.tournaments.assignment_revoked")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,32 @@
module Public
class TournamentGroupsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
position = @tournament.groups.maximum(:position).to_i + 1
name = params.dig(:tournament_group, :name).presence || "Girone #{('A'.ord + position).chr}"
@tournament.groups.create!(name: name, position: position)
redirect_to public_tournament_path(@tournament, tab: "struttura"), notice: t("flash.tournaments.group_added")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.groups.find(params[:group_id]).destroy!
redirect_to public_tournament_path(@tournament, tab: "struttura"), notice: t("flash.tournaments.group_removed")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,136 @@
module Public
class TournamentInvitationsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
if params[:intent] == "save"
save_draft
return
end
match_ids = Array(params[:match_ids]).reject(&:blank?)
scope_kind = if match_ids.any?
"matches"
elsif params[:whole_court].present? || params[:scope_kind].to_s == "court_day"
"court_day"
else
raise ArgumentError, t("tournaments.hub.stream_plan_need_selection")
end
persist_draft! unless calendar_invite?
invitation, token = Tournaments::Invite.call(
tournament: @tournament,
email: params[:email],
scope_kind: scope_kind,
match_ids: match_ids,
court: params[:court],
on_date: params[:on_date].presence,
invited_by: current_user,
note: params[:note]
)
invite_url = public_invitation_url(token: token)
flash[:invite_url] = invite_url unless calendar_invite?
body_html = Tournaments::ComposeInviteEmail.call(
html: draft_html,
invite_url: invite_url,
expires_on: I18n.l(invitation.expires_at.to_date, format: :long)
)
invitation.update!(email_html: body_html)
begin
Tournaments::InvitationMailer.transmission_invite(
tournament: @tournament,
invitation: invitation,
invite_url: invite_url,
invited_by: current_user,
body_html: body_html
).deliver_now
flash[:notice] = t("flash.tournaments.invite_email_sent", email: invitation.email)
rescue StandardError => e
Rails.logger.error("[tournament_invite] #{e.class}: #{e.message}")
flash[:alert] = t("flash.tournaments.invite_email_failed", email: invitation.email)
end
redirect_to public_tournament_path(@tournament, tab: invite_return_tab, anchor: calendar_invite? ? nil : "invito-generato")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid, ArgumentError => e
redirect_to public_tournament_path(@tournament, tab: invite_return_tab), alert: e.message
end
def save_draft
Tournaments::Entitlements.new(@club).assert_writable!
persist_draft!
redirect_to public_tournament_path(@tournament, tab: "dirette"),
notice: t("flash.tournaments.invite_draft_saved")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "dirette"), alert: e.message
end
def upload_image
Tournaments::Entitlements.new(@club).assert_writable!
file = params[:file]
unless file.respond_to?(:content_type) && file.content_type.to_s.in?(%w[image/png image/jpeg image/webp image/gif])
return render json: { error: t("tournaments.hub.invite_image_invalid") }, status: :unprocessable_entity
end
if file.size > 2.megabytes
return render json: { error: t("tournaments.hub.invite_image_too_big") }, status: :unprocessable_entity
end
@tournament.invite_email_images.attach(file)
blob = @tournament.invite_email_images.blobs.last
render json: { url: url_for(blob) }
rescue Tournaments::EntitlementError => e
render json: { error: e.message }, status: :forbidden
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
invitation = @tournament.broadcast_invitations.find(params[:invitation_id])
invitation.destroy!
redirect_to public_tournament_path(@tournament, tab: invite_return_tab), notice: t("flash.tournaments.invite_canceled")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def persist_draft!
@tournament.update!(
invite_draft_html: Tournaments::ComposeInviteEmail.sanitize_html(draft_html),
invite_draft_note: params[:note].to_s.strip.presence,
invite_draft_email: params[:email].to_s.strip.presence,
invite_draft_saved_at: Time.current
)
end
def draft_html
html = params[:email_html].to_s
stripped = ActionController::Base.helpers.strip_tags(html).to_s.gsub(/\s+/, "")
return html if stripped.present?
stored = @tournament.invite_draft_html.to_s
if ActionController::Base.helpers.strip_tags(stored).to_s.gsub(/\s+/, "").present?
return stored
end
Tournaments::ComposeInviteEmail.default_html(tournament: @tournament, invited_by: current_user)
end
def calendar_invite?
params[:from].to_s == "calendar" || invite_return_tab == "calendario"
end
def invite_return_tab
tab = params[:tab].to_s
tab = "dirette" if tab == "delega"
tab.presence_in(%w[calendario dirette]) || "dirette"
end
end
end
@@ -0,0 +1,113 @@
module Public
class TournamentMatchesController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::ScheduleMatch.call(tournament: @tournament, attrs: match_params)
redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.match_scheduled")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
def update
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
attrs = match_params
if match.result_recorded?
attrs = attrs.except(:home_participant_id, :away_participant_id)
end
match.update!(attrs)
schedule_changed = match.previous_changes.keys.intersect?(%w[scheduled_at court location])
recorded = record_result_if_present!(match)
notice = if recorded && !schedule_changed
t("flash.tournaments.result_saved")
else
t("flash.tournaments.match_updated")
end
redirect_to public_tournament_path(@tournament, tab: hub_tab), notice: notice
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
def swap
match = @tournament.matches.find(params[:match_id])
Tournaments::SwapSides.call(match: match)
respond_to do |format|
format.json { render json: { ok: true, matchup_label: match.reload.matchup_label } }
format.html { redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.sides_swapped") }
end
rescue Tournaments::EntitlementError => e
respond_to do |format|
format.json { render json: { ok: false, error: e.message }, status: :unprocessable_entity }
format.html { redirect_to public_club_billing_path(@club), alert: e.message }
end
rescue Tournaments::SwapSides::LiveBroadcastError => e
respond_to do |format|
format.json { render json: { ok: false, error: e.message }, status: :unprocessable_entity }
format.html { redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.message }
end
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
unless match.deletable?
redirect_to public_tournament_path(@tournament, tab: "calendario"),
alert: t("flash.matches.close_live_before_delete")
return
end
match.destroy!
redirect_to public_tournament_path(@tournament, tab: "calendario"), notice: t("flash.tournaments.match_deleted")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def record_result_if_present!(match)
home = params[:home_score]
away = params[:away_score]
return false if home.blank? || away.blank?
Tournaments::RecordResult.call(match: match, home_score: home, away_score: away, source: "manual")
true
end
def match_params
p = params.require(:match).permit(
:home_participant_id, :away_participant_id, :tournament_group_id,
:tournament_round_id, :court, :location, :scheduled_at
)
%i[home_participant_id away_participant_id].each do |key|
p[key] = p[key].presence if p.key?(key)
end
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
p
end
def parse_scheduled_at(value)
raw = value.to_s.strip
return nil if raw.blank?
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
rescue ArgumentError
Time.zone.parse(raw)
end
def hub_tab
params[:tab].to_s.presence_in(%w[squadre struttura calendario dirette tabellone]) || "calendario"
end
end
end
@@ -0,0 +1,81 @@
module Public
class TournamentPagesController < SiteBaseController
include Public::LiveHelper
layout "marketing_live"
def index
@tournaments = Tournament
.visible_to_public
.with_attached_logo_file
.includes(:club)
.order(starts_on: :desc, name: :asc)
end
def show
@tournament = Tournament.find_by!(slug: params[:slug])
unless @tournament.published? || owner_access?
raise ActiveRecord::RecordNotFound
end
@club = @tournament.club
@owner_preview = !@tournament.published? && owner_access?
@owner_manage = owner_access?
@tab = params[:tab].to_s.presence_in(%w[risultati tabellone]) || "risultati"
load_public_content!
end
private
def owner_access?
logged_in? && @tournament.club.owned_by?(current_user)
end
def load_public_content!
@online_paths = fetch_online_paths
@matches = @tournament.matches
.includes(
:tournament_group,
:tournament_round,
:stream_sessions,
home_participant: { logo_file_attachment: :blob },
away_participant: { logo_file_attachment: :blob }
)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
broadcasting = StreamSession
.broadcasting
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
.where(match_id: @tournament.matches.select(:id))
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
.to_a
@live_by_match_id = {}
broadcasting.each { |session| @live_by_match_id[session.match_id] ||= session }
@live_sessions = broadcasting.select(&:public_watchable?)
@recordings = Recording.ready.publicly_listed
.joins(stream_session: :match)
.where(matches: { tournament_id: @tournament.id })
.includes(stream_session: { match: [:home_participant, :away_participant] })
.order(recorded_at: :desc)
@recording_by_match_id = {}
@recordings.each do |rec|
match_id = rec.stream_session.match_id
@recording_by_match_id[match_id] ||= rec
end
@groups = @tournament.groups.order(:position)
@standings_by_group = @groups.index_with { |group| Tournaments::Standings.call(group) }
@rounds = @tournament.rounds.order(:position)
@matches_by_round = @matches.select { |match| match.tournament_round_id.present? }
.group_by(&:tournament_round_id)
end
def fetch_online_paths
Mediamtx::Client.new.online_path_names
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
[]
end
end
end
@@ -0,0 +1,51 @@
module Public
class TournamentParticipantsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.participants.create!(participant_params)
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_added")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "squadre"), alert: e.record.errors.full_messages.join(", ")
end
def update
Tournaments::Entitlements.new(@club).assert_writable!
participant = @tournament.participants.find(params[:participant_id])
participant.update!(participant_params)
file = params.dig(:tournament_participant, :logo_file)
participant.logo_file.attach(file) if file.present?
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_updated")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "squadre"), alert: e.record.errors.full_messages.join(", ")
end
def destroy
Tournaments::Entitlements.new(@club).assert_writable!
@tournament.participants.find(params[:participant_id]).destroy!
redirect_to public_tournament_path(@tournament, tab: "squadre"), notice: t("flash.tournaments.participant_removed")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
def participant_params
permitted = params.require(:tournament_participant).permit(:name, :group_id, :primary_color, :logo_url, :source_team_id)
permitted[:group_id] = permitted[:group_id].presence if permitted.key?(:group_id)
permitted
end
end
end
@@ -0,0 +1,32 @@
module Public
class TournamentResultsController < WebBaseController
before_action :require_login!
before_action :set_tournament
def create
Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id])
Tournaments::RecordResult.call(
match: match,
home_score: params[:home_score],
away_score: params[:away_score],
source: "manual",
walkover: params[:walkover].presence
)
redirect_to public_tournament_path(@tournament, tab: params[:tab].presence || "calendario"),
notice: t("flash.tournaments.result_saved")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.record.errors.full_messages.join(", ")
end
private
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
require_club_owner!(@club)
end
end
end
@@ -0,0 +1,190 @@
module Public
class TournamentsController < WebBaseController
before_action :require_login!
before_action :set_club, only: %i[index new create]
before_action :set_tournament, except: %i[index new create]
before_action :require_owner!
before_action :require_writable!, only: %i[update generate_group_matches propose_knockout]
def index
@tournaments = @club.tournaments.order(starts_on: :desc)
@can_create = Tournaments::Entitlements.new(@club).premium_full?
end
def new
assert_full!
return if performed?
@tournament = @club.tournaments.build(
sport_key: Sports::Catalog.normalize_key(@club.sport),
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "mixed",
courts: ["Campo 1", "Campo 2"],
knockout_size: 4
)
end
def create
@tournament = Tournaments::Create.call(club: @club, attrs: tournament_params)
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.created")
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
rescue ActiveRecord::RecordInvalid => e
@tournament = e.record
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
def show
if params[:tab] == "delega"
redirect_to public_tournament_path(@tournament, tab: "dirette")
return
end
load_hub!
end
def update
attrs = tournament_params
file = attrs.delete(:logo_file)
@tournament.assign_attributes(attrs)
@tournament.logo_file.attach(file) if file.present?
@tournament.save!
redirect_to public_tournament_path(@tournament, tab: hub_tab || params[:tab]), notice: t("flash.tournaments.updated")
rescue ActiveRecord::RecordInvalid => e
load_hub!
flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :show, status: :unprocessable_entity
end
def publish
assert_full!
return if performed?
@tournament.update!(status: "published")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.published")
end
def unpublish
assert_full!
return if performed?
@tournament.update!(status: "draft")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.unpublished")
end
def archive
assert_full!
return if performed?
@tournament.update!(status: "archived")
redirect_to public_tournament_path(@tournament), notice: t("flash.tournaments.archived")
end
def destroy
Tournaments::Destroy.call(tournament: @tournament)
redirect_to public_club_tournaments_path(@club), notice: t("flash.tournaments.deleted")
rescue Tournaments::Destroy::LiveBroadcastError => e
redirect_to public_tournament_path(@tournament), alert: e.message
end
def generate_group_matches
created = Tournaments::GenerateGroupMatches.call(tournament: @tournament)
redirect_to public_tournament_path(@tournament, tab: "calendario"),
notice: t("flash.tournaments.group_matches_created", count: created.size)
rescue Tournaments::EntitlementError, ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "calendario"), alert: e.message
end
def propose_knockout
updated = Tournaments::ProposeKnockout.call(@tournament)
redirect_to public_tournament_path(@tournament, tab: "tabellone"),
notice: t("flash.tournaments.knockout_proposed", count: updated.size)
rescue Tournaments::EntitlementError, ActiveRecord::RecordInvalid => e
redirect_to public_tournament_path(@tournament, tab: "tabellone"), alert: e.message
end
private
def set_club
@club = Club.find(params[:club_id] || params[:id])
end
def set_tournament
@tournament = Tournament.find(params[:id])
@club = @tournament.club
end
def require_owner!
require_club_owner!(@club)
end
def assert_full!
Tournaments::Entitlements.new(@club).assert_writable!
rescue Tournaments::EntitlementError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def require_writable!
assert_full!
return if performed?
return if @tournament.writable?
redirect_to public_tournament_path(@tournament), alert: t("flash.tournaments.archived_locked")
end
def load_hub!
@tab = hub_tab || "squadre"
@participants = @tournament.participants.with_attached_logo_file.includes(:group, :source_team).order(:position, :name)
@groups = @tournament.groups.order(:position)
@rounds = @tournament.rounds.order(:position)
@matches = @tournament.matches.includes(
{ home_participant: { logo_file_attachment: :blob } },
{ away_participant: { logo_file_attachment: :blob } },
:tournament_group, :tournament_round, :stream_sessions,
{ broadcast_assignments: :user }
).order(:scheduled_at)
@invitations = @tournament.broadcast_invitations.order(created_at: :desc)
@pending_invites_by_match_id = pending_invites_by_match_id
@standings_by_group = @groups.index_with { |group| Tournaments::Standings.call(group) }
@overlap_warnings = overlap_warnings
@writable = @tournament.writable? && Tournaments::Entitlements.new(@club).premium_full?
end
def hub_tab
raw = params[:tab].to_s
raw = "dirette" if raw == "delega"
raw.presence_in(%w[squadre struttura calendario dirette tabellone])
end
def pending_invites_by_match_id
pending = @tournament.broadcast_invitations.pending.to_a
map = Hash.new { |h, k| h[k] = [] }
@matches.each do |match|
pending.each do |invitation|
map[match.id] << invitation if invitation.covers_match?(match)
end
end
map
end
def overlap_warnings
limit = @tournament.concurrent_limit
return [] unless limit
@matches.group_by { |m| m.scheduled_at&.strftime("%Y-%m-%d %H:%M") }.filter_map do |slot, list|
next if slot.blank? || list.size <= limit
t("tournaments.hub.overlap_warning", slot: slot, count: list.size, limit: limit)
end
end
def tournament_params
params.require(:tournament).permit(
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
:description, :knockout_size, :courts, :logo_file
)
end
end
end
+24 -1
View File
@@ -53,6 +53,18 @@ module Public
def live_match_card_heading(match, link_team: true)
team = match.team
club_name = team.club&.name.presence || t("score.default_club_name")
if match.tournament_match?
tournament = match.tournament
heading_name = tournament&.name.presence || club_name
matchup = match.matchup_label
return content_tag(:h3, class: "live-card__title") do
safe_join([
content_tag(:span, heading_name, class: "live-card__club"),
content_tag(:span, matchup, class: "live-card__matchup")
])
end
end
team_slug = team.respond_to?(:slug) ? team.slug : nil
team_label = if link_team && team_slug.present?
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
@@ -68,6 +80,17 @@ module Public
end
def live_match_page_heading(match)
if match.tournament_match?
tournament = match.tournament
club_name = tournament&.name.presence || match.team.club&.name.presence || t("score.default_club_name")
return content_tag(:div, class: "live-page-heading") do
safe_join([
content_tag(:p, club_name, class: "live-page-heading__club"),
content_tag(:h1, match.matchup_label, class: "live-page-heading__matchup")
])
end
end
team = match.team
club_name = team.club&.name.presence || t("score.default_club_name")
content_tag(:div, class: "live-page-heading") do
@@ -91,7 +114,7 @@ module Public
else score_state.away_points
end
t("score.points_label", team: match.team.name, home: home, away: away, opponent: match.opponent_name)
t("score.points_label", team: match.home_display_name, home: home, away: away, opponent: match.away_display_name)
end
private
@@ -0,0 +1,175 @@
module Public
module TournamentsHelper
def tournament_team_chip(participant, name: nil)
label = name.presence || participant&.name.presence || t("tournaments.tbd")
logo = participant&.effective_logo_url
content_tag(:span, class: "tournament-team-chip") do
parts = []
if logo.present?
parts << image_tag(logo, alt: "", width: 28, height: 28)
end
parts << content_tag(:span, label)
safe_join(parts)
end
end
def tournament_datetime_local(time)
time&.in_time_zone&.strftime("%Y-%m-%dT%H:%M")
end
def tournament_invite_default_html(tournament, invited_by)
Tournaments::ComposeInviteEmail.default_html(tournament: tournament, invited_by: invited_by)
end
def tournament_invite_editor_html(tournament, invited_by)
stored = tournament.invite_draft_html.to_s
return tournament_invite_default_html(tournament, invited_by) if stored.blank?
Tournaments::ComposeInviteEmail.sanitize_html(stored)
end
def tournament_streaming_operator_label(user)
user&.name.presence || user&.email.presence || ""
end
def tournament_pending_invites_for(match)
Array(@pending_invites_by_match_id&.[](match.id))
end
def tournament_invite_cancel_confirm(invitation)
count = @matches.to_a.count { |match| invitation.covers_match?(match) }
if count > 1
t("tournaments.hub.streaming_cancel_multi", email: invitation.email, count: count)
else
t("tournaments.hub.streaming_cancel", email: invitation.email)
end
end
def tournament_matches_grouped_by_date(matches)
matches.group_by { |match| match.scheduled_at&.in_time_zone&.to_date }
end
def tournament_streaming_coverage(matches)
assigned = pending = open = 0
matches.each do |match|
if match.broadcast_assignments.any?
assigned += 1
elsif tournament_pending_invites_for(match).any?
pending += 1
else
open += 1
end
end
{ assigned: assigned, pending: pending, open: open, total: matches.size }
end
def tournament_broadcast_live_state(match)
sessions = match.stream_sessions.to_a
latest = sessions.max_by(&:created_at)
if latest && !latest.status.in?(%w[ended error])
return :live if latest.status.in?(%w[live reconnecting paused connecting])
return :waiting_operator
end
return :ended if sessions.any? { |session| session.status.in?(%w[ended error]) } || match.played?
if match.broadcast_assignments.any?
return :waiting_time if match.scheduled_upcoming?
return :waiting_operator
end
return :waiting_operator if tournament_pending_invites_for(match).any?
:uncovered
end
def tournament_streaming_live_counts(matches)
counts = Hash.new(0)
matches.each { |match| counts[tournament_broadcast_live_state(match)] += 1 }
counts
end
def tournament_public_live_session(match)
@live_by_match_id&.[](match.id)
end
def tournament_public_recording(match)
@recording_by_match_id&.[](match.id)
end
def tournament_public_board_state(match)
return :live if tournament_public_live_session(match)
return :replay if tournament_public_recording(match)
return :ended if match.result_recorded?
return :scheduled if match.scheduled_upcoming?
:waiting
end
def tournament_public_watch_target(session)
return unless session
if session.matchlivetv_platform? && session.publicly_listed?
return [public_live_path(session), t("tournaments.page.watch_live"), {}]
end
if session.youtube_watch_url.present?
return [session.youtube_watch_url, t("live.show.youtube_watch_link"), { target: "_blank", rel: "noopener" }]
end
nil
end
def tournament_public_score_label(match)
return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present?
session = tournament_public_live_session(match)
score = session&.score_state
return unless score
"#{score.home_points}#{score.away_points}"
end
def tournament_match_sides_locked?(match)
match.result_recorded?
end
def tournament_phase_label(match)
match.tournament_round&.name.presence || match.tournament_group&.name.presence || ""
end
def tournament_side_blank_label(match, side)
source = tournament_side_source_label(match, side)
source.presence || t("tournaments.tbd")
end
def tournament_side_source_label(match, side)
kind = match.public_send("#{side}_source_kind").to_s
case kind
when "winner_match"
source = tournament_source_match(match, side)
return t("tournaments.hub.winner_tbd") if source.blank?
t("tournaments.hub.winner_of", match: source.matchup_label)
when "group_rank"
group = tournament_source_group(match, side)
rank = match.public_send("#{side}_source_rank").to_i
return t("tournaments.hub.group_rank_tbd") if group.blank? || rank <= 0
t("tournaments.hub.group_rank", group: group.name, rank: rank)
end
end
def tournament_source_match(match, side)
id = match.public_send("#{side}_source_match_id")
return if id.blank?
@matches&.find { |item| item.id == id } || Match.find_by(id: id)
end
def tournament_source_group(match, side)
id = match.public_send("#{side}_source_group_id")
return if id.blank?
@groups&.find { |item| item.id == id } || TournamentGroup.find_by(id: id)
end
end
end
@@ -0,0 +1,33 @@
# frozen_string_literal: true
module Streams
class NightCloudSweeperJob
include Sidekiq::Job
sidekiq_options retry: 1, queue: "default"
INTERVAL_SECS = ENV.fetch("STREAM_NIGHT_SWEEP_INTERVAL_SECS", "900").to_i
REDIS_CHAIN_KEY = "streams:night_sweep:chain"
def self.ensure_chain
return unless redis
return if redis.get(REDIS_CHAIN_KEY)
redis.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
perform_in(INTERVAL_SECS)
end
def self.redis
@redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
rescue Redis::CannotConnectError
nil
end
def perform
Streams::NightCloudSweeper.sweep!
ensure
self.class.redis&.set(REDIS_CHAIN_KEY, "1", ex: INTERVAL_SECS * 2)
self.class.perform_in(INTERVAL_SECS)
end
end
end
@@ -0,0 +1,27 @@
module Tournaments
class InvitationMailer < ApplicationMailer
default from: -> { MatchLiveTv.mail_from }
def transmission_invite(tournament:, invitation:, invite_url:, invited_by:, body_html: nil)
@tournament = tournament
@club = tournament.club
@invitation = invitation
@invite_url = invite_url
@invited_by = invited_by
@expires_on = I18n.l(invitation.expires_at.to_date, format: :long)
@body_html = body_html.presence || invitation.email_html.presence
@body_text = ActionController::Base.helpers.strip_tags(@body_html.to_s).squish
I18n.with_locale(I18n.locale) do
mail(
to: invitation.email,
subject: t(
"mailers.tournament_invite.subject",
tournament: @tournament.name,
club: @club.name
)
)
end
end
end
end
+1
View File
@@ -6,6 +6,7 @@ class Club < ApplicationRecord
has_many :club_memberships, dependent: :destroy
has_many :users, through: :club_memberships
has_many :teams, dependent: :destroy
has_many :tournaments, dependent: :restrict_with_error
has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
+70 -2
View File
@@ -2,7 +2,14 @@ class Match < ApplicationRecord
include Coverable
belongs_to :team
belongs_to :tournament, optional: true
belongs_to :home_participant, class_name: "TournamentParticipant", optional: true
belongs_to :away_participant, class_name: "TournamentParticipant", optional: true
belongs_to :tournament_group, optional: true
belongs_to :tournament_round, optional: true
belongs_to :winner_participant, class_name: "TournamentParticipant", optional: true
has_many :stream_sessions, dependent: :destroy
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
has_one_attached :opponent_logo_file
@@ -20,6 +27,8 @@ class Match < ApplicationRecord
before_validation :normalize_sport_key
before_validation :inherit_sport_from_team, on: :create
before_validation :sync_tournament_display_names
after_commit :sync_tournament_broadcast_assignments, on: %i[create update]
scope :scheduled_for_live, -> {
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
@@ -85,14 +94,27 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end
def result_recorded?
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
end
def coach_hub_visible?
active = active_stream_session
return true if active&.resumable?
return true if active&.idle?
return false if result_recorded?
return false if stream_completed?
return true if scheduled_on_calendar?
return true if tournament_open_for_late_stream?
scheduled_upcoming?
false
end
def tournament_open_for_late_stream?
return false unless tournament_match?
event = tournament
event.present? && !event.archived? && event.ends_on >= Date.current
end
def effective_board_type
@@ -116,6 +138,30 @@ class Match < ApplicationRecord
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
def tournament_match?
has_attribute?(:tournament_id) && tournament_id.present?
end
def home_display_name
home_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : team.name)
end
def away_display_name
away_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : opponent_name.presence)
end
def matchup_label
"#{home_display_name} vs #{away_display_name}"
end
def played?
result_status == "played"
end
def court_or_location
court.presence || location
end
# Alias legacy API/spec (colonna rinominata in sport_key).
def sport
sport_key
@@ -132,12 +178,34 @@ class Match < ApplicationRecord
end
def inherit_sport_from_team
if has_attribute?(:tournament_id) && tournament.present?
self.sport_key = tournament.sport_key if new_record? || sport_key.blank?
return
end
return unless team.present?
# Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione.
self.sport_key = team.sport_key if new_record? || sport_key.blank?
end
def sync_tournament_display_names
return unless has_attribute?(:tournament_id) && tournament_id.present?
self.opponent_name = away_display_name if away_participant.present? || opponent_name.blank?
venue_court = [tournament&.venue, court].compact_blank
self.location = venue_court.join("") if venue_court.any?
self.category = tournament.name if category.blank? && tournament.present?
end
def sync_tournament_broadcast_assignments
return unless has_attribute?(:tournament_id) && tournament_id.present?
return unless saved_change_to_court? || saved_change_to_scheduled_at? || previously_new_record?
Tournaments::SyncAssignments.sync_match!(self)
end
def sport_key_known
return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key)
+22 -2
View File
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
end
def title_or_default
title.presence || default_title
match = stream_session&.match
return title.presence || "Replay" unless match
generated = match.matchup_label
stored = title.to_s.strip
return generated if stored.blank?
return generated if stale_auto_title?(match, stored)
stored
end
def default_title
match = stream_session&.match
return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}"
match.matchup_label
end
def recorded_at_or_fallback
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
private
def stale_auto_title?(match, stored)
return false unless match.tournament_match?
team_name = match.team&.name.to_s
return false if team_name.blank?
[
"#{team_name} vs #{match.opponent_name}",
"#{team_name} vs #{match.away_display_name}"
].include?(stored)
end
def normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank?
+6
View File
@@ -186,6 +186,12 @@ class StreamSession < ApplicationRecord
platform == "youtube" && youtube_watch_url.present?
end
def public_watchable?
return true if matchlivetv_platform? && publicly_listed?
youtube_ready?
end
def link_only?
privacy_status.in?(%w[unlisted private])
end
+10
View File
@@ -2,6 +2,8 @@ class Team < ApplicationRecord
include Brandable
include Coverable
INTERNAL_KINDS = %w[tournament_broadcast].freeze
belongs_to :club
has_many :user_teams, dependent: :destroy
has_many :users, through: :user_teams
@@ -9,6 +11,10 @@ class Team < ApplicationRecord
has_many :recordings, dependent: :destroy
has_many :team_invitations, dependent: :destroy
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
has_one :broadcast_tournament, class_name: "Tournament", foreign_key: :broadcast_team_id, inverse_of: :broadcast_team
scope :visible, -> { where("internal_kind IS NULL OR internal_kind = ''") }
scope :tournament_broadcast, -> { where(internal_kind: Tournament::INTERNAL_TEAM_KIND) }
has_one_attached :photo_file
@@ -61,6 +67,10 @@ class Team < ApplicationRecord
Rails.application.routes.url_helpers.public_team_page_path(slug)
end
def tournament_broadcast?
internal_kind == Tournament::INTERNAL_TEAM_KIND
end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
+145
View File
@@ -0,0 +1,145 @@
class Tournament < ApplicationRecord
include Coverable
FORMAT_KINDS = %w[groups knockout mixed free].freeze
STATUSES = %w[draft published live archived].freeze
INTERNAL_TEAM_KIND = "tournament_broadcast"
belongs_to :club
belongs_to :broadcast_team, class_name: "Team", optional: true
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
has_many :matches, dependent: :destroy
has_many :broadcast_invitations, class_name: "TournamentBroadcastInvitation", dependent: :destroy
has_many :participants, class_name: "TournamentParticipant", dependent: :destroy
has_many :rounds, class_name: "TournamentRound", dependent: :destroy
has_many :groups, class_name: "TournamentGroup", dependent: :destroy
has_many_attached :invite_email_images
has_one_attached :logo_file
validates :name, presence: true
validates :slug, presence: true, uniqueness: true,
format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" }
validates :sport_key, presence: true
validates :starts_on, :ends_on, presence: true
validates :format_kind, inclusion: { in: FORMAT_KINDS }
validates :status, inclusion: { in: STATUSES }
validates :invite_draft_note, length: { maximum: 2000 }, allow_blank: true
validate :sport_key_known
validate :ends_on_not_before_starts_on
validate :logo_file_type, if: -> { logo_file.attached? }
before_validation :normalize_sport_key
before_validation :assign_slug, on: :create
before_validation :normalize_slug, if: -> { slug_changed? && slug.present? }
before_validation :normalize_courts
scope :visible_to_public, -> { where(status: %w[published live archived]) }
scope :listed_on_live, -> { where(status: %w[published live]) }
scope :search_public, lambda { |query|
q = query.to_s.strip
return all if q.blank?
term = "%#{sanitize_sql_like(q)}%"
left_joins(:club).where(
"tournaments.name ILIKE :term OR clubs.name ILIKE :term OR tournaments.venue ILIKE :term",
term: term
)
}
def published?
status.in?(%w[published live archived])
end
def archived?
status == "archived"
end
def writable?
!archived?
end
def uses_groups?
format_kind.in?(%w[groups mixed])
end
def uses_knockout?
format_kind.in?(%w[knockout mixed])
end
def court_list
Array(courts).map { |c| c.to_s.strip }.reject(&:blank?)
end
def days
(starts_on..ends_on).to_a
end
def sport_label
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
end
def public_page_path
Rails.application.routes.url_helpers.public_tournament_page_path(slug)
end
def effective_primary_color
club.effective_primary_color
end
def effective_secondary_color
club.effective_secondary_color
end
def effective_logo_url
return unless logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
end
def concurrent_limit
club.subscription&.plan&.concurrent_streams_limit
end
private
def normalize_sport_key
self.sport_key = Sports::Catalog.normalize_key(sport_key) if sport_key.present?
end
def sport_key_known
return if sport_key.blank?
return if Sports::Catalog.find_optional(sport_key)
errors.add(:sport_key, "non valido")
end
def ends_on_not_before_starts_on
return if starts_on.blank? || ends_on.blank?
return if ends_on >= starts_on
errors.add(:ends_on, "non può precedere la data di inizio")
end
def assign_slug
self.slug = Tournaments::GenerateSlug.call(self) if slug.blank?
end
def normalize_slug
self.slug = slug.to_s.parameterize
end
def normalize_courts
self.courts = court_list
self.courts = ["Campo 1"] if courts.blank?
end
def cover_parent
club
end
def logo_file_type
return if logo_file.content_type.in?(Coverable::COVER_IMAGE_TYPES)
errors.add(:logo_file, I18n.t("coverable.errors.invalid_type"))
end
end
@@ -0,0 +1,8 @@
class TournamentBroadcastAssignment < ApplicationRecord
belongs_to :tournament
belongs_to :match
belongs_to :user
belongs_to :invitation, class_name: "TournamentBroadcastInvitation", optional: true
validates :user_id, uniqueness: { scope: :match_id }
end
@@ -0,0 +1,55 @@
class TournamentBroadcastInvitation < ApplicationRecord
SCOPE_KINDS = %w[matches court_day].freeze
belongs_to :tournament
belongs_to :accepted_by, class_name: "User", optional: true
has_many :assignments, class_name: "TournamentBroadcastAssignment",
foreign_key: :invitation_id, dependent: :destroy
validates :email, presence: true, format: { with: URI::MailTo::EMAIL_REGEXP }
validates :token_digest, presence: true, uniqueness: true
validates :scope_kind, inclusion: { in: SCOPE_KINDS }
validates :court, presence: true, if: -> { scope_kind == "court_day" }
validates :on_date, presence: true, if: -> { scope_kind == "court_day" }
validates :note, length: { maximum: 2000 }, allow_blank: true
scope :pending, -> { where(accepted_at: nil).where("expires_at > ?", Time.current) }
scope :accepted, -> { where.not(accepted_at: nil) }
def self.generate_token
SecureRandom.urlsafe_base64(32)
end
def expired?
expires_at.past?
end
def covers_match?(match)
return false if match.blank? || match.tournament_id != tournament_id
case scope_kind
when "court_day"
return false if court.blank? || on_date.blank? || match.scheduled_at.blank?
court.to_s == match.court.to_s && on_date == match.scheduled_at.in_time_zone.to_date
else
Array(match_ids).map(&:to_s).include?(match.id.to_s)
end
end
def accept!(user)
transaction do
update!(accepted_at: Time.current, accepted_by: user)
Tournaments::GrantBroadcastAccess.call(invitation: self, user: user)
Tournaments::SyncAssignments.call(invitation: self)
end
end
def scope_label
if scope_kind == "court_day"
"#{court} · #{I18n.l(on_date)}"
else
"#{Array(match_ids).size} partite"
end
end
end
+8
View File
@@ -0,0 +1,8 @@
class TournamentGroup < ApplicationRecord
belongs_to :tournament
has_many :participants, class_name: "TournamentParticipant", foreign_key: :group_id, dependent: :nullify
has_many :matches, dependent: :nullify
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
@@ -0,0 +1,35 @@
class TournamentParticipant < ApplicationRecord
include Brandable
belongs_to :tournament
belongs_to :group, class_name: "TournamentGroup", optional: true
belongs_to :source_team, class_name: "Team", optional: true
has_many :home_matches, class_name: "Match", foreign_key: :home_participant_id, dependent: :nullify
has_many :away_matches, class_name: "Match", foreign_key: :away_participant_id, dependent: :nullify
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
after_update :sync_related_match_names, if: :saved_change_to_name?
def branding_parent
source_team || tournament
end
def effective_logo_url
if logo_file.attached?
Rails.application.routes.url_helpers.rails_blob_path(logo_file, only_path: true)
elsif logo_url.present?
logo_url
else
source_team&.effective_logo_url
end
end
private
def sync_related_match_names
Match.where(home_participant_id: id).or(Match.where(away_participant_id: id)).find_each(&:save!)
end
end
+10
View File
@@ -0,0 +1,10 @@
class TournamentRound < ApplicationRecord
KINDS = %w[round_of_16 quarterfinal semifinal final third_place].freeze
belongs_to :tournament
has_many :matches, dependent: :nullify
validates :kind, inclusion: { in: KINDS }
validates :name, presence: true
validates :position, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
end
+15 -3
View File
@@ -12,16 +12,19 @@ class User < ApplicationRecord
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
has_many :tournament_broadcast_assignments, dependent: :destroy
def manageable_teams
staff_ids = teams.select(:id)
owner_ids = Team.where(club_id: owned_clubs.select(:id)).select(:id)
owner_ids = Team.visible.where(club_id: owned_clubs.select(:id)).select(:id)
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
end
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
def streamable_teams
manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
listed = manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
owned_broadcast = Team.tournament_broadcast.where(club_id: owned_clubs.select(:id)).includes(:club)
(listed + owned_broadcast.to_a).uniq
end
def club_admin?(club)
@@ -29,6 +32,7 @@ class User < ApplicationRecord
end
def can_schedule_for?(team)
return false if team.tournament_broadcast?
return true if team.club&.owned_by?(self)
membership = user_teams.find_by(team: team)
@@ -38,7 +42,7 @@ class User < ApplicationRecord
end
def schedulable_teams_for(club)
teams = club.teams.order(:name).to_a
teams = club.teams.visible.order(:name).to_a
return teams if club.owned_by?(self)
teams.select { |team| can_schedule_for?(team) }
@@ -55,6 +59,14 @@ class User < ApplicationRecord
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
end
def can_broadcast_match?(match)
return true if match.team.club&.owned_by?(self)
return false unless can_stream_for?(match.team)
return true unless match.tournament_match?
tournament_broadcast_assignments.exists?(match_id: match.id)
end
def staff_role_for(team)
return "owner" if team.club&.owned_by?(self)
@@ -73,7 +73,7 @@ module Recordings
def default_title
match = @session.match
"#{match.team.name} vs #{match.opponent_name}"
match.matchup_label.presence || "Replay"
end
def privacy_from_session
@@ -35,9 +35,9 @@ module Recordings
FileUtils.remove_entry(dest)
return nil
end
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
unless res.is_a?(Net::HTTPSuccess) && body_present?(res.body)
FileUtils.remove_entry(dest)
raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}"
raise Error, "agent GET recordings HTTP #{res.code} #{safe_body_snippet(res.body)}"
end
tar_path = File.join(dest, "recordings.tar.gz")
@@ -84,6 +84,14 @@ module Recordings
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def body_present?(body)
body && !body.empty?
end
def safe_body_snippet(body)
body.to_s.dup.force_encoding("UTF-8").scrub[0, 200]
end
def unpack!(tar_path, dest)
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
raise Error, "tar extract failed" unless ok
+5
View File
@@ -15,6 +15,11 @@ module Sessions
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
remove_mediamtx_paths!
begin
Tournaments::CaptureStreamResult.call(@session)
rescue StandardError => e
Rails.logger.warn("[Sessions::Stop] tournament result: #{e.class}: #{e.message}")
end
log_event("ended")
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
@session
+12 -1
View File
@@ -71,6 +71,10 @@ module Streams
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
end
def quiet_hours?
QuietHours.active?
end
def reconcile!(provisioner: nil)
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
@@ -106,7 +110,8 @@ module Streams
allow_cloud: allow_cloud?,
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
monthly_budget_eur: monthly_budget_eur,
within_budget: within_budget?(overflow.size)
within_budget: within_budget?(overflow.size),
quiet_hours: quiet_hours?
}
end
@@ -201,6 +206,7 @@ module Streams
end
def warm_spare_desired?(m)
return false if self.class.quiet_hours?
return false if self.class.warm_spare_min <= 0
need_capacity?(m) || overflow_in_use?
@@ -211,6 +217,7 @@ module Streams
end
def can_provision?(m)
return false if self.class.quiet_hours?
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
@@ -249,6 +256,9 @@ module Streams
end
def idle_long_enough?(node)
# Di notte chiudi subito i nodi idle (niente attesa IDLE_MINUTES).
return true if self.class.quiet_hours?
idle_since(node) <= self.class.idle_minutes.minutes.ago
end
@@ -258,6 +268,7 @@ module Streams
end
def keep_as_warm_spare?(node)
return false if self.class.quiet_hours?
return false unless warm_spare_desired?(self.class.metrics)
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
@@ -0,0 +1,83 @@
# frozen_string_literal: true
module Streams
# Durante quiet hours chiude CPX cloud idle; se hanno sessioni attive solo alert Ops.
class NightCloudSweeper
Result = Struct.new(:skipped, :actions, :error, keyword_init: true)
class << self
def enabled?
ENV.fetch("STREAM_NIGHT_SWEEP_ENABLED", "1") == "1"
end
def sweep!(provisioner: nil)
new(provisioner: provisioner).sweep!
end
end
def initialize(provisioner: nil)
@provisioner = provisioner || NodeProvisioner.new
end
def sweep!
unless self.class.enabled?
return Result.new(skipped: true, actions: [], error: "disabled")
end
unless QuietHours.active?
return Result.new(skipped: true, actions: [], error: "outside_quiet_hours")
end
actions = []
cloud_nodes.find_each do |node|
if node.occupying_sessions.exists?
alert_active_night_node!(node)
actions << :"alert_active_#{node.slug}"
next
end
close_idle_node!(node)
actions << :"decommission_#{node.slug}"
rescue NodeProvisioner::BusyError, NodeProvisioner::Error => e
Rails.logger.warn("[Streams::NightCloudSweeper] #{node.slug}: #{e.message}")
actions << :"error_#{node.slug}"
end
Rails.logger.info("[Streams::NightCloudSweeper] actions=#{actions.inspect}")
Result.new(skipped: false, actions: actions)
end
private
def cloud_nodes
StreamNode.where(role: "cloud").where(status: %w[ready draining provisioning])
end
def alert_active_night_node!(node)
Ops::IncidentRecorder.record(
{
kind: "stream_overflow",
severity: "warning",
title: "CPX attivo di notte — verificare evento",
message: "Nodo #{node.slug} ha #{node.active_publishers} sessione/i in quiet hours (#{QuietHours.range_config} #{QuietHours.time_zone_name})",
metadata: {
"slug" => node.slug,
"session_ids" => node.occupying_sessions.pluck(:id),
"quiet_hours" => QuietHours.range_config
},
fingerprint: "stream_overflow:night_active:#{node.slug}"
}
)
end
def close_idle_node!(node)
@provisioner.drain!(node) unless node.status == "draining"
node.reload
raise NodeProvisioner::BusyError, "sessioni ancora attive" if node.occupying_sessions.exists?
slug = node.slug
@provisioner.decommission!(node)
Rails.logger.warn("[Streams::NightCloudSweeper] decommissioned idle cloud node #{slug} during quiet hours")
Ops::IncidentRecorder.resolve(fingerprint: "stream_overflow:night_active:#{slug}")
end
end
end
@@ -0,0 +1,59 @@
# frozen_string_literal: true
module Streams
# Finestra notturna (default 02:0007:00 Europe/Rome) in cui non devono restare CPX idle.
module QuietHours
DEFAULT_RANGE = "02:00-07:00"
DEFAULT_TZ = "Europe/Rome"
module_function
def range_config
ENV.fetch("STREAM_AUTOSCALE_QUIET_HOURS", DEFAULT_RANGE).to_s.strip
end
def time_zone_name
ENV.fetch("STREAM_AUTOSCALE_QUIET_TZ", DEFAULT_TZ)
end
def configured?
range_config.present? && range_config != "off" && range_config != "0"
end
def active?(now: Time.current)
return false unless configured?
zone = ActiveSupport::TimeZone[time_zone_name] || Time.find_zone!(time_zone_name)
local = now.in_time_zone(zone)
start_min, end_min = parse_range(range_config)
return false if start_min.nil? || end_min.nil?
current = local.hour * 60 + local.min
if start_min <= end_min
current >= start_min && current < end_min
else
# es. 22:00-06:00
current >= start_min || current < end_min
end
rescue ArgumentError => e
Rails.logger.warn("[Streams::QuietHours] invalid config: #{e.message}")
false
end
def parse_range(raw)
start_s, end_s = raw.split("-", 2).map { |p| p.to_s.strip }
return [nil, nil] if start_s.blank? || end_s.blank?
[parse_hhmm(start_s), parse_hhmm(end_s)]
end
def parse_hhmm(value)
h, m = value.split(":", 2)
hours = Integer(h)
mins = Integer(m || 0)
raise ArgumentError, "ora fuori range: #{value}" unless hours.between?(0, 23) && mins.between?(0, 59)
hours * 60 + mins
end
end
end
@@ -45,7 +45,7 @@ module Teams
end
def base_teams
scope = Team.includes(:club).where(id: active_team_ids)
scope = Team.visible.includes(:club).where(id: active_team_ids)
scope = scope.where(sport_key: @sport) if @sport
scope = apply_search(scope) if @q.present?
scope.to_a
@@ -0,0 +1,35 @@
module Tournaments
class CaptureStreamResult
def self.call(session)
new(session).call
end
def initialize(session)
@session = session
end
def call
match = @session.match
return unless match&.tournament_match?
score = @session.score_state
return unless score
board = match.effective_board_type
home, away, extra = case board
when "basket", "timed"
[score.basket_home_score, score.basket_away_score, { "board" => board }]
else
[score.home_sets, score.away_sets, { "board" => board, "partials" => score.set_partials }]
end
Tournaments::RecordResult.call(
match: match,
home_score: home,
away_score: away,
source: "stream",
result_data: extra
)
end
end
end
@@ -0,0 +1,118 @@
# frozen_string_literal: true
module Tournaments
class ComposeInviteEmail
LINK_TOKEN = "{{link_invito}}"
EXPIRY_TOKEN = "{{scadenza}}"
MAX_HTML_BYTES = 100_000
ALLOWED_TAGS = %w[p br strong b em i u ul ol li a img h2 h3 h4 span div blockquote].freeze
ALLOWED_ATTR = %w[href src alt width height style target rel data-invite-note].freeze
ALLOWED_STYLE = %w[
width height max-width min-width margin margin-top margin-bottom margin-left margin-right
padding padding-top padding-bottom padding-left padding-right
float display text-align color background background-color
font-weight font-size line-height text-decoration border-radius border
].freeze
def self.call(html:, invite_url:, expires_on: nil)
new(html: html, invite_url: invite_url, expires_on: expires_on).call
end
def self.sanitize_html(html)
new(html: html.to_s, invite_url: "").sanitize(html.to_s)
end
def self.default_html(tournament:, invited_by:)
intro = I18n.t(
"mailers.tournament_invite.body_html",
inviter: CGI.escapeHTML(invited_by.name.to_s),
tournament: CGI.escapeHTML(tournament.name.to_s),
club: CGI.escapeHTML(tournament.club.name.to_s),
assignment: I18n.t("tournaments.hub.invite_email_scope_generic")
)
cta = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
<<~HTML
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.hello"))}</p>
<p>#{intro}</p>
<p><a href="#{LINK_TOKEN}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{cta}</a></p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.link_fallback"))}<br>#{LINK_TOKEN}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.steps"))}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.expiry", date: EXPIRY_TOKEN))}</p>
<p>#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.ignore"))}</p>
HTML
end
def initialize(html:, invite_url:, expires_on: nil)
@html = html.to_s
@invite_url = invite_url.to_s
@expires_on = expires_on.to_s
end
def call
html = @html.dup
html.gsub!(LINK_TOKEN, @invite_url)
html.gsub!(EXPIRY_TOKEN, @expires_on) if @expires_on.present?
sanitized = sanitize(html)
sanitized += fallback_cta if @invite_url.present? && !sanitized.include?(@invite_url)
sanitized
end
def sanitize(html)
html = html.bytesize > MAX_HTML_BYTES ? html.byteslice(0, MAX_HTML_BYTES) : html
fragment = Loofah.fragment(html.to_s)
fragment.css("[data-invite-note]").each do |el|
el.remove if el.text.to_s.strip.blank?
end
scrubber = Rails::HTML::PermitScrubber.new
scrubber.tags = ALLOWED_TAGS
scrubber.attributes = ALLOWED_ATTR
fragment.scrub!(scrubber)
fragment.css("a").each { |node| scrub_url!(node, "href") }
fragment.css("img").each do |node|
scrub_url!(node, "src")
end
fragment.css("[style]").each { |node| scrub_style!(node) }
fragment.css("a[target='_blank']").each do |node|
node["rel"] = "noopener noreferrer"
end
fragment.to_s
end
private
def scrub_url!(node, attr)
url = node[attr].to_s.strip
return if url == LINK_TOKEN || url.include?(LINK_TOKEN)
return if url.match?(/\Ahttps?:\/\//i) || url.start_with?("/rails/active_storage")
if attr == "src"
node.remove
else
node.remove_attribute(attr)
end
end
def scrub_style!(node)
decls = node["style"].to_s.split(";").map(&:strip).reject(&:blank?)
kept = decls.select do |decl|
prop, value = decl.split(":", 2).map { |part| part.to_s.strip }
next false if prop.blank? || value.blank?
next false unless ALLOWED_STYLE.include?(prop.downcase)
next false if value.match?(/expression|javascript|url\s*\(/i)
true
end
if kept.any?
node["style"] = kept.join("; ")
else
node.remove_attribute("style")
end
end
def fallback_cta
label = CGI.escapeHTML(I18n.t("mailers.tournament_invite.cta"))
url = CGI.escapeHTML(@invite_url)
%(<p><a href="#{url}" style="display:inline-block;background:#e53935;color:#ffffff;text-decoration:none;padding:12px 18px;border-radius:8px;font-weight:600;">#{label}</a></p><p>#{url}</p>)
end
end
end
@@ -0,0 +1,87 @@
module Tournaments
class Create
KNOCKOUT_ROUNDS = {
16 => %w[round_of_16 quarterfinal semifinal final],
8 => %w[quarterfinal semifinal final],
4 => %w[semifinal final],
2 => %w[final]
}.freeze
ROUND_LABELS = {
"round_of_16" => "Ottavi",
"quarterfinal" => "Quarti",
"semifinal" => "Semifinali",
"final" => "Finale",
"third_place" => "Finale 3° posto"
}.freeze
def self.call(club:, attrs:)
new(club: club, attrs: attrs).call
end
def initialize(club:, attrs:)
@club = club
@attrs = attrs
end
def call
Tournaments::Entitlements.new(@club).assert_creatable!
tournament = @club.tournaments.build(filtered_attrs)
tournament.sport_key = Sports::Catalog.normalize_key(tournament.sport_key.presence || @club.sport)
Tournament.transaction do
tournament.save!
attach_logo!(tournament)
Tournaments::EnsureBroadcastTeam.call(tournament)
create_default_groups!(tournament)
create_knockout_rounds!(tournament)
end
tournament
end
private
def filtered_attrs
@attrs.to_h.symbolize_keys.slice(
:name, :sport_key, :venue, :starts_on, :ends_on, :format_kind,
:description, :knockout_size, :courts
).tap do |h|
h[:courts] = parse_courts(h[:courts]) if h.key?(:courts)
h[:knockout_size] = h[:knockout_size].to_i if h[:knockout_size].present?
h[:knockout_size] = nil if h[:knockout_size].to_i <= 0
end
end
def parse_courts(value)
case value
when Array then value
else value.to_s.split(/[\n,]/)
end
end
def attach_logo!(tournament)
file = @attrs.to_h.symbolize_keys[:logo_file]
tournament.logo_file.attach(file) if file.present?
end
def create_default_groups!(tournament)
return unless tournament.uses_groups?
return if tournament.groups.exists?
["Girone A", "Girone B"].each_with_index do |name, idx|
tournament.groups.create!(name: name, position: idx)
end
end
def create_knockout_rounds!(tournament)
return unless tournament.uses_knockout?
return if tournament.rounds.exists?
size = tournament.knockout_size.presence || 4
kinds = KNOCKOUT_ROUNDS[size] || KNOCKOUT_ROUNDS[4]
kinds.each_with_index do |kind, idx|
tournament.rounds.create!(kind: kind, name: ROUND_LABELS[kind], position: idx)
end
end
end
end
@@ -0,0 +1,46 @@
module Tournaments
class Destroy
class LiveBroadcastError < StandardError; end
def self.call(tournament:)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
raise LiveBroadcastError, I18n.t("flash.tournaments.delete_blocked_live") if live_broadcast?
team = @tournament.broadcast_team
Tournament.transaction do
purge_recordings!
@tournament.update_column(:broadcast_team_id, nil) if team
@tournament.destroy!
destroy_orphan_broadcast_team!(team)
end
end
private
def live_broadcast?
StreamSession.where(
match_id: @tournament.matches.select(:id),
status: %w[connecting live reconnecting paused]
).exists?
end
def purge_recordings!
session_ids = StreamSession.where(match_id: @tournament.matches.select(:id)).select(:id)
Recording.where(stream_session_id: session_ids).find_each(&:destroy!)
end
def destroy_orphan_broadcast_team!(team)
return unless team&.tournament_broadcast?
return if Tournament.exists?(broadcast_team_id: team.id)
team.destroy!
end
end
end
@@ -0,0 +1,23 @@
module Tournaments
class EnsureBroadcastTeam
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
return @tournament.broadcast_team if @tournament.broadcast_team.present?
team = @tournament.club.teams.create!(
name: @tournament.name,
sport_key: @tournament.sport_key,
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
@tournament.update!(broadcast_team: team)
team
end
end
end
@@ -0,0 +1,43 @@
module Tournaments
class EntitlementError < StandardError
attr_reader :code, :billing_url
def initialize(message, code:, billing_url: nil)
super(message)
@code = code
@billing_url = billing_url
end
end
class Entitlements
def initialize(club)
@club = club
end
def subscription
@subscription ||= @club.subscription
end
def premium_full?
subscription&.premium_full? == true
end
def billing_url
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
end
def assert_creatable!
assert_writable!
end
def assert_writable!
unless premium_full?
raise EntitlementError.new(
"I tornei sono disponibili con il piano Premium Full.",
code: "premium_full_required",
billing_url: billing_url
)
end
end
end
end
@@ -0,0 +1,40 @@
module Tournaments
class FillKnockoutSources
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
@tournament.matches.find_each do |match|
next if match.home_source_kind.blank? && match.away_source_kind.blank?
home = resolve(match, :home)
away = resolve(match, :away)
attrs = {}
attrs[:home_participant] = home if home && match.home_participant_id.blank?
attrs[:away_participant] = away if away && match.away_participant_id.blank?
match.update!(attrs) if attrs.any?
end
end
private
def resolve(match, side)
kind = match.public_send("#{side}_source_kind")
case kind
when "winner_match"
source = Match.find_by(id: match.public_send("#{side}_source_match_id"))
source&.winner_participant
when "group_rank"
group = TournamentGroup.find_by(id: match.public_send("#{side}_source_group_id"))
rank = match.public_send("#{side}_source_rank").to_i
return nil unless group && rank.positive?
Tournaments::Standings.call(group)[rank - 1]&.participant
end
end
end
end
@@ -0,0 +1,72 @@
module Tournaments
class GenerateGroupMatches
def self.call(tournament:, start_at: nil)
new(tournament, start_at: start_at).call
end
def initialize(tournament, start_at: nil)
@tournament = tournament
@start_at = start_at
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
created = []
cursor = @start_at || Time.zone.local(@tournament.starts_on.year, @tournament.starts_on.month, @tournament.starts_on.day, 9, 0, 0)
courts = @tournament.court_list
court_idx = 0
@tournament.groups.includes(:participants).order(:position).each do |group|
pairs = round_robin(group.participants.to_a)
pairs.each do |home, away|
next if home.blank? || away.blank?
match = Tournaments::ScheduleMatch.call(
tournament: @tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
tournament_group_id: group.id,
court: courts[court_idx % courts.size],
scheduled_at: cursor
}
)
created << match
court_idx += 1
if (court_idx % courts.size).zero?
cursor += 1.hour
cursor = next_day_morning(cursor) if cursor.to_date > @tournament.ends_on
end
end
end
created
end
private
def round_robin(participants)
list = participants.dup
return [] if list.size < 2
list << nil if list.size.odd?
n = list.size
rounds = n - 1
pairs = []
rounds.times do
(n / 2).times do |i|
a = list[i]
b = list[n - 1 - i]
pairs << [a, b] if a && b
end
list = [list[0]] + [list[-1]] + list[1..-2]
end
pairs
end
def next_day_morning(time)
nxt = time.to_date + 1.day
nxt = @tournament.starts_on if nxt > @tournament.ends_on
Time.zone.local(nxt.year, nxt.month, nxt.day, 9, 0, 0)
end
end
end
@@ -0,0 +1,30 @@
module Tournaments
class GenerateSlug
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
base = @tournament.name.to_s.parameterize.presence || "torneo"
slug = base
n = 2
while conflict?(slug)
slug = "#{base}-#{n}"
n += 1
end
slug
end
private
def conflict?(slug)
scope = Tournament.where(slug: slug)
scope = scope.where.not(id: @tournament.id) if @tournament.persisted?
scope.exists?
end
end
end
@@ -0,0 +1,21 @@
module Tournaments
class GrantBroadcastAccess
def self.call(invitation:, user:)
new(invitation: invitation, user: user).call
end
def initialize(invitation:, user:)
@invitation = invitation
@user = user
end
def call
team = Tournaments::EnsureBroadcastTeam.call(@invitation.tournament)
membership = UserTeam.find_or_initialize_by(user: @user, team: team)
membership.role = "member" if membership.new_record?
membership.staff_kind = "transmission"
membership.save!
membership
end
end
end
@@ -0,0 +1,45 @@
module Tournaments
class Invite
def self.call(tournament:, email:, scope_kind:, match_ids: [], court: nil, on_date: nil, invited_by:, note: nil)
new(
tournament: tournament,
email: email,
scope_kind: scope_kind,
match_ids: match_ids,
court: court,
on_date: on_date,
invited_by: invited_by,
note: note
).call
end
def initialize(tournament:, email:, scope_kind:, match_ids:, court:, on_date:, invited_by:, note: nil)
@tournament = tournament
@email = email.to_s.downcase.strip
@scope_kind = scope_kind.to_s
@match_ids = Array(match_ids).reject(&:blank?)
@court = court
@on_date = on_date
@invited_by = invited_by
@note = note.to_s.strip.presence
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
raise ArgumentError, "Email non valida" if @email.blank?
token = TournamentBroadcastInvitation.generate_token
invitation = @tournament.broadcast_invitations.create!(
email: @email,
token_digest: Digest::SHA256.hexdigest(token),
scope_kind: @scope_kind,
match_ids: @match_ids,
court: @court,
on_date: @on_date,
note: @note,
expires_at: 7.days.from_now
)
[invitation, token]
end
end
end
@@ -0,0 +1,95 @@
module Tournaments
class ProposeKnockout
def self.call(tournament)
new(tournament).call
end
def initialize(tournament)
@tournament = tournament
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
first_round = @tournament.rounds.order(:position).first
return [] unless first_round
pairs = pair_qualified
slots = first_round.matches.order(:scheduled_at, :created_at).to_a
slots = create_slots!(first_round, [pairs.size, 1].max) if slots.empty?
updated = []
slots.each_with_index do |match, idx|
home, away = pairs[idx]
next if home.blank? && away.blank?
match.update!(home_participant: home, away_participant: away)
updated << match
end
updated.concat(seed_later_rounds!(slots))
updated
end
private
def pair_qualified
groups = @tournament.groups.order(:position).to_a
ranked = groups.map { |group| Tournaments::Standings.call(group).map(&:participant) }
return ranked.flatten.each_slice(2).to_a if groups.size < 2
pairs = []
first = ranked[0] || []
second = ranked[1] || []
pairs << [first[0], second[1]] if first[0] || second[1]
pairs << [second[0], first[1]] if second[0] || first[1]
leftover = (first.drop(2) + second.drop(2) + ranked.drop(2).flatten)
pairs.concat(leftover.each_slice(2).to_a)
pairs
end
def seed_later_rounds!(first_round_matches)
prev = first_round_matches
created = []
@tournament.rounds.order(:position).offset(1).each do |round|
needed = [prev.size / 2, 1].max
slots = round.matches.order(:scheduled_at, :created_at).to_a
last_at = prev.map(&:scheduled_at).compact.max
start = last_at&.+(1.hour)
slots = create_slots!(round, needed, start: start) if slots.empty?
slots.each_with_index do |match, idx|
home_src = prev[idx * 2]
away_src = prev[idx * 2 + 1]
attrs = {}
if home_src && match.home_source_match_id.blank?
attrs[:home_source_kind] = "winner_match"
attrs[:home_source_match_id] = home_src.id
end
if away_src && match.away_source_match_id.blank?
attrs[:away_source_kind] = "winner_match"
attrs[:away_source_match_id] = away_src.id
end
match.update!(attrs) if attrs.any?
created << match
end
prev = slots
end
created
end
def create_slots!(round, n, start: nil)
count = [n, 1].max
day = @tournament.ends_on
start ||= Time.zone.local(day.year, day.month, day.day, 18, 0, 0)
Array.new(count) do |i|
Tournaments::ScheduleMatch.call(
tournament: @tournament,
attrs: {
tournament_round_id: round.id,
court: @tournament.court_list.first,
scheduled_at: start + i.hours
}
)
end
end
end
end
@@ -0,0 +1,58 @@
module Tournaments
class RecordResult
def self.call(match:, home_score:, away_score:, source: "manual", result_data: {}, walkover: nil)
new(
match: match,
home_score: home_score,
away_score: away_score,
source: source,
result_data: result_data,
walkover: walkover
).call
end
def initialize(match:, home_score:, away_score:, source:, result_data:, walkover:)
@match = match
@home_score = home_score
@away_score = away_score
@source = source
@result_data = result_data || {}
@walkover = walkover
end
def call
return @match unless @match.tournament_match?
status = "played"
winner_id = nil
if @walkover == "home"
status = "walkover_home"
winner_id = @match.home_participant_id
@home_score = @home_score.presence || 1
@away_score = 0
elsif @walkover == "away"
status = "walkover_away"
winner_id = @match.away_participant_id
@away_score = @away_score.presence || 1
@home_score = 0
else
winner_id = if @home_score.to_i > @away_score.to_i
@match.home_participant_id
elsif @away_score.to_i > @home_score.to_i
@match.away_participant_id
end
end
@match.update!(
home_score: @home_score,
away_score: @away_score,
result_status: status,
result_source: @source,
result_data: @result_data,
winner_participant_id: winner_id
)
Tournaments::FillKnockoutSources.call(@match.tournament)
@match
end
end
end
@@ -0,0 +1,20 @@
module Tournaments
class RevokeAssignment
def self.call(assignment:)
new(assignment).call
end
def initialize(assignment)
@assignment = assignment
end
def call
invitation = @assignment.invitation
@assignment.destroy!
return unless invitation
leftover = invitation.assignments.reload.pluck(:match_id).map(&:to_s)
invitation.update!(scope_kind: "matches", match_ids: leftover, court: nil, on_date: nil)
end
end
end
@@ -0,0 +1,34 @@
module Tournaments
class ScheduleMatch
def self.call(tournament:, attrs:)
new(tournament: tournament, attrs: attrs).call
end
def initialize(tournament:, attrs:)
@tournament = tournament
@attrs = attrs.to_h.symbolize_keys
end
def call
Tournaments::Entitlements.new(@tournament.club).assert_writable!
raise Tournaments::EntitlementError.new("Torneo archiviato", code: "archived") unless @tournament.writable?
team = Tournaments::EnsureBroadcastTeam.call(@tournament)
match = team.matches.build(
tournament: @tournament,
sport_key: @tournament.sport_key,
home_participant_id: @attrs[:home_participant_id].presence,
away_participant_id: @attrs[:away_participant_id].presence,
tournament_group_id: @attrs[:tournament_group_id].presence,
tournament_round_id: @attrs[:tournament_round_id].presence,
court: @attrs[:court].presence,
location: @attrs[:location].presence,
scheduled_at: @attrs[:scheduled_at],
opponent_name: "TBD",
sets_to_win: @attrs[:sets_to_win].presence || 3
)
match.save!
match
end
end
end
@@ -0,0 +1,115 @@
module Tournaments
class Standings
Row = Struct.new(
:participant, :played, :won, :lost, :drawn, :points,
:sets_for, :sets_against, :score_for, :score_against,
keyword_init: true
)
def self.call(group)
new(group).call
end
def initialize(group)
@group = group
@tournament = group.tournament
end
def call
rows = @group.participants.with_attached_logo_file.map { |p| blank_row(p) }.index_by { |r| r.participant.id }
matches = @group.matches.where(result_status: %w[played walkover_home walkover_away])
matches.find_each do |match|
apply_match!(rows, match)
end
rows.values.sort_by { |row| [-row.points, -set_diff(row), -score_diff(row), row.participant.name] }
end
private
def blank_row(participant)
Row.new(
participant: participant, played: 0, won: 0, lost: 0, drawn: 0, points: 0,
sets_for: 0, sets_against: 0, score_for: 0, score_against: 0
)
end
def apply_match!(rows, match)
home = rows[match.home_participant_id]
away = rows[match.away_participant_id]
return unless home && away
hs = match.home_score.to_i
as = match.away_score.to_i
home.played += 1
away.played += 1
home.sets_for += hs
home.sets_against += as
away.sets_for += as
away.sets_against += hs
home.score_for += hs
home.score_against += as
away.score_for += as
away.score_against += hs
if hs == as
home.drawn += 1
away.drawn += 1
home.points += split_draw_points
away.points += split_draw_points
return
end
if hs > as
home.won += 1
away.lost += 1
home.points += win_points(hs, as)
away.points += loss_points(hs, as)
else
away.won += 1
home.lost += 1
away.points += win_points(as, hs)
home.points += loss_points(as, hs)
end
end
def settings
@settings ||= (@tournament.scoring_settings || {}).stringify_keys
end
def split_sets?
ActiveModel::Type::Boolean.new.cast(settings["split_sets"])
end
def win_points(winner_sets, loser_sets)
return settings.fetch("win_points", 3).to_i unless split_sets?
if winner_sets - loser_sets >= 2
settings.fetch("win_3_0_or_3_1", 3).to_i
else
settings.fetch("win_3_2", 2).to_i
end
end
def loss_points(winner_sets, loser_sets)
return settings.fetch("loss_points", 0).to_i unless split_sets?
if winner_sets - loser_sets >= 2
settings.fetch("loss_0_3_or_1_3", 0).to_i
else
settings.fetch("loss_2_3", 1).to_i
end
end
def split_draw_points
settings.fetch("draw_points", 1).to_i
end
def set_diff(row)
row.sets_for - row.sets_against
end
def score_diff(row)
row.score_for - row.score_against
end
end
end
@@ -0,0 +1,67 @@
module Tournaments
class SwapSides
class LiveBroadcastError < StandardError; end
def self.call(match:)
new(match).call
end
def initialize(match)
@match = match
end
def call
tournament = @match.tournament
raise ArgumentError, "not a tournament match" unless tournament
Tournaments::Entitlements.new(tournament.club).assert_writable!
unless tournament.writable?
raise Tournaments::EntitlementError.new(
I18n.t("flash.tournaments.archived_locked"),
code: "archived"
)
end
raise LiveBroadcastError, I18n.t("flash.matches.close_live_before_delete") unless @match.deletable?
home_id = @match.home_participant_id
away_id = @match.away_participant_id
home_score = @match.home_score
away_score = @match.away_score
home_source_kind = @match.home_source_kind
away_source_kind = @match.away_source_kind
home_source_match_id = @match.home_source_match_id
away_source_match_id = @match.away_source_match_id
home_source_group_id = @match.home_source_group_id
away_source_group_id = @match.away_source_group_id
home_source_rank = @match.home_source_rank
away_source_rank = @match.away_source_rank
@match.update!(
home_participant_id: away_id,
away_participant_id: home_id,
home_score: away_score,
away_score: home_score,
home_source_kind: away_source_kind,
away_source_kind: home_source_kind,
home_source_match_id: away_source_match_id,
away_source_match_id: home_source_match_id,
home_source_group_id: away_source_group_id,
away_source_group_id: home_source_group_id,
home_source_rank: away_source_rank,
away_source_rank: home_source_rank,
result_status: swapped_result_status
)
@match
end
private
def swapped_result_status
case @match.result_status
when "walkover_home" then "walkover_away"
when "walkover_away" then "walkover_home"
else @match.result_status
end
end
end
end
@@ -0,0 +1,57 @@
module Tournaments
class SyncAssignments
def self.call(invitation:)
new(invitation).sync_invitation!
end
def self.sync_match!(match)
return unless match.tournament_id.present?
match.tournament.broadcast_invitations.accepted.find_each do |invitation|
new(invitation).sync_invitation!
end
end
def initialize(invitation)
@invitation = invitation
end
def sync_invitation!
return unless @invitation.accepted_at.present?
return unless @invitation.accepted_by_id.present?
desired_ids = resolve_match_ids
existing = @invitation.assignments.index_by(&:match_id)
desired_ids.each do |match_id|
next if existing[match_id]
TournamentBroadcastAssignment.find_or_create_by!(
tournament_id: @invitation.tournament_id,
match_id: match_id,
user_id: @invitation.accepted_by_id
) do |row|
row.invitation = @invitation
end
end
stale = existing.keys - desired_ids
@invitation.assignments.where(match_id: stale).delete_all if stale.any?
end
private
def resolve_match_ids
matches = @invitation.tournament.matches
case @invitation.scope_kind
when "court_day"
matches
.where(court: @invitation.court)
.where("scheduled_at >= ? AND scheduled_at < ?", @invitation.on_date.beginning_of_day, @invitation.on_date.end_of_day)
.pluck(:id)
else
Array(@invitation.match_ids).map(&:to_s).intersection(matches.pluck(:id).map(&:to_s))
end
end
end
end
+3 -1
View File
@@ -10,7 +10,7 @@
<%= render "shared/analytics_suppress" %>
<%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=81">
<link rel="stylesheet" href="/marketing.css?v=102">
</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" %>
@@ -23,6 +23,8 @@
</main>
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" defer></script>
<script src="/tournament-invite-editor.js?v=2" defer></script>
<script src="/tournament-calendar.js?v=2" defer></script>
<script src="/roster-form.js?v=1" defer></script>
<script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=81">
<link rel="stylesheet" href="/marketing.css?v=102">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
@@ -21,6 +21,15 @@
<%= link_to t("club.dashboard.edit_club"), public_edit_club_path(@club), class: "btn btn-secondary" %>
<%= link_to t("club.dashboard.subscription"), public_club_billing_path(@club), class: "btn btn-primary" %>
<%= link_to t("club.dashboard.new_team"), public_new_club_team_path(@club), class: "btn btn-secondary" %>
<% if Tournaments::Entitlements.new(@club).premium_full? %>
<%= link_to t("club.dashboard.tournaments"), public_club_tournaments_path(@club), class: "btn btn-secondary" %>
<% else %>
<%= link_to public_club_billing_path(@club), class: "btn btn-secondary btn-gated",
title: t("club.dashboard.tournaments_locked_hint") do %>
<%= t("club.dashboard.tournaments") %>
<span class="btn-gated__ribbon" aria-hidden="true"><%= t("club.dashboard.tournaments_full_badge") %></span>
<% end %>
<% end %>
<%= link_to t("club.dashboard.live_streams"), public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
<% if @entitlements&.can_access_recordings? %>
<%= link_to t("club.dashboard.replay_archive"), public_club_recordings_path(@club), class: "btn btn-secondary" %>
@@ -1,13 +1,21 @@
<% content_for :title, t("auth.invitation.meta_title") %>
<% content_for :meta_description, t("auth.invitation.meta_description") %>
<% content_for :robots, "noindex, nofollow" %>
<%
display_name = if @tournament_invitation
@tournament_invitation.tournament.name
else
@invitation.team.name
end
email = (@invitation || @tournament_invitation).email
%>
<section class="auth-page">
<div class="card">
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
<h1><%= raw t("auth.invitation.title_html", team_name: display_name) %></h1>
<p><%= raw t("auth.invitation.role_notice_html", email: email) %></p>
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
<%= raw t("auth.invitation.instructions_html", email: @invitation.email) %>
<%= raw t("auth.invitation.instructions_html", email: email) %>
</p>
<% if logged_in? %>
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
@@ -16,7 +24,7 @@
"auth.invitation.login_or_signup_html",
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
email: @invitation.email
email: email
) %></p>
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
<% end %>
+18 -3
View File
@@ -118,13 +118,28 @@
</div>
<% end %>
<% if @sessions.empty? %>
<% if @upcoming_matches.any? %>
<% if @sessions.empty? && @upcoming_matches.any? %>
<div class="empty-state empty-state--soft">
<p><strong><%= t("live.index.empty_soft_title") %></strong></p>
<p><%= t("live.index.empty_soft_body") %></p>
</div>
<% elsif @club %>
<% end %>
<% if @public_tournaments.any? %>
<h2 class="section-heading<%= " section-heading--spaced" if @sessions.any? || @upcoming_matches.any? %>"><%= t("tournaments.directory.section_on_live") %></h2>
<p class="results-hint"><%= t("tournaments.directory.live_hint") %></p>
<div class="team-directory-grid">
<% @public_tournaments.each do |tournament| %>
<%= render "public/tournament_pages/directory_card", tournament: tournament %>
<% end %>
</div>
<p class="team-public-more">
<%= link_to t("tournaments.directory.all_link"), public_tournament_pages_path %>
</p>
<% end %>
<% if @sessions.empty? && @upcoming_matches.empty? && @public_tournaments.blank? %>
<% if @club %>
<div class="empty-state">
<p><strong><%= t("live.index.empty_club_title", name: @club.name) %></strong></p>
<p>
@@ -0,0 +1,52 @@
<% state = tournament_public_board_state(match) %>
<% live_session = tournament_public_live_session(match) %>
<% recording = tournament_public_recording(match) %>
<% score = tournament_public_score_label(match) %>
<tr class="tournament-board__row tournament-board__row--<%= state %>">
<td>
<% if match.scheduled_at %>
<%= match.scheduled_at.in_time_zone.strftime("%H:%M") %>
<% else %>
<% end %>
</td>
<td><%= match.court.presence || "—" %></td>
<td>
<div class="tournament-stream-plan__matchup">
<%= tournament_team_chip(match.home_participant, name: match.home_display_name) %>
<span class="tournament-stream-plan__vs"></span>
<%= tournament_team_chip(match.away_participant, name: match.away_display_name) %>
</div>
<% if match.tournament_group || match.tournament_round %>
<p class="tournament-board__phase">
<%= [match.tournament_group&.name, match.tournament_round&.name].compact.join(" · ") %>
</p>
<% end %>
</td>
<td>
<% if score.present? %>
<strong><%= score %></strong>
<% else %>
<% end %>
</td>
<td>
<span class="tournament-live-status tournament-live-status--<%= state %>">
<span class="tournament-live-status__dot" aria-hidden="true"></span>
<%= t("tournaments.page.state.#{state}") %>
</span>
</td>
<td class="tournament-board__actions">
<% watch = tournament_public_watch_target(live_session) %>
<% if watch %>
<% url, label, html_opts = watch %>
<%= link_to label, url, { class: "tournament-board__cta tournament-board__cta--live" }.merge(html_opts) %>
<% elsif live_session %>
<span class="muted"><%= t("tournaments.page.no_media") %></span>
<% elsif recording %>
<%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %>
<% else %>
<span class="muted"><%= t("tournaments.page.no_media") %></span>
<% end %>
</td>
</tr>
@@ -0,0 +1,28 @@
<% club = tournament.club %>
<% logo = tournament.effective_logo_url %>
<%= link_to public_tournament_page_path(tournament.slug), class: "team-directory-card tournament-directory-card" do %>
<div class="team-directory-card__media">
<% if logo.present? %>
<%= image_tag logo, alt: "", class: "team-directory-card__photo team-directory-card__photo--logo", width: 72, height: 72 %>
<% else %>
<div class="team-directory-card__photo team-directory-card__photo--fallback" aria-hidden="true">
<%= tournament.name.to_s.first %>
</div>
<% end %>
</div>
<div class="team-directory-card__body">
<p class="team-directory-card__club"><%= club.name %></p>
<h2 class="team-directory-card__name"><%= tournament.name %></h2>
<p class="team-directory-card__meta">
<%= tournament.sport_label %>
· <%= l(tournament.starts_on) %> <%= l(tournament.ends_on) %>
<% if tournament.venue.present? %> · <%= tournament.venue %><% end %>
</p>
<div class="team-directory-card__badges">
<% if tournament.archived? %>
<span class="badge badge-scheduled"><%= t("tournaments.status.archived") %></span>
<% end %>
<span class="tournament-directory-follow"><%= t("tournaments.directory.follow") %></span>
</div>
</div>
<% end %>
@@ -0,0 +1,65 @@
<section class="team-public-section" id="risultati" aria-labelledby="tournament-results-heading">
<h2 id="tournament-results-heading" class="section-heading visually-hidden"><%= t("tournaments.page.tabs.risultati") %></h2>
<p class="results-hint"><%= t("tournaments.page.board_hint") %></p>
<% if @matches.any? %>
<% tournament_matches_grouped_by_date(@matches).each do |date, matches| %>
<h3 class="tournament-board__day">
<%= date ? l(date, format: :long) : t("tournaments.hub.stream_plan_unscheduled") %>
</h3>
<div class="tournament-table-wrap">
<table class="data tournament-board">
<thead>
<tr>
<th><%= t("tournaments.hub.datetime") %></th>
<th><%= t("tournaments.hub.court") %></th>
<th><%= t("tournaments.hub.stream_plan_match") %></th>
<th><%= t("tournaments.page.score") %></th>
<th><%= t("tournaments.hub.stream_plan_live_col") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% matches.each do |match| %>
<%= render "public/tournament_pages/board_row", match: match %>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("tournaments.hub.no_matches") %></p>
<% end %>
</section>
<% if @recordings.any? %>
<section class="team-public-section">
<h2 class="section-heading section-heading--spaced"><%= t("tournaments.page.section_replays") %></h2>
<div class="replay-grid">
<% @recordings.each do |rec| %>
<% match = rec.stream_session.match %>
<%= link_to public_replay_path(rec.stream_session_id), class: "replay-card" do %>
<div class="replay-card__media">
<% if rec.thumbnail_url %>
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-card__thumb" loading="lazy" width="320" height="180">
<% else %>
<div class="replay-card__thumb replay-card__thumb--placeholder" aria-hidden="true">
<span class="replay-card__placeholder-icon"></span>
</div>
<% end %>
<% if rec.duration_label.present? %>
<span class="replay-card__duration"><%= rec.duration_label %></span>
<% end %>
<span class="replay-card__play" aria-hidden="true"></span>
</div>
<div class="replay-card__body">
<% phase = [match.tournament_round&.name, match.tournament_group&.name].compact.first %>
<strong class="replay-card__title"><%= match.matchup_label %></strong>
<p class="replay-card__meta">
<%= [phase, rec.recorded_at_or_fallback && l(rec.recorded_at_or_fallback, format: :short)].compact.join(" · ") %>
</p>
</div>
<% end %>
<% end %>
</div>
</section>
<% end %>
@@ -0,0 +1,60 @@
<% visible_rounds = @rounds.select { |round| (@matches_by_round[round.id] || []).any? } %>
<section class="team-public-section" id="tabellone" aria-labelledby="tournament-bracket-heading">
<h2 id="tournament-bracket-heading" class="section-heading visually-hidden"><%= t("tournaments.page.tabs.tabellone") %></h2>
<% @groups.each do |group| %>
<h3 class="tournament-board__day"><%= group.name %><%= t("tournaments.hub.standings") %></h3>
<div class="tournament-table-wrap">
<table class="data">
<thead>
<tr>
<th></th>
<th><%= t("tournaments.hub.played") %></th>
<th><%= t("tournaments.hub.won") %></th>
<th><%= t("tournaments.hub.lost") %></th>
<th><%= t("tournaments.hub.points") %></th>
</tr>
</thead>
<tbody>
<% @standings_by_group[group].each_with_index do |row, idx| %>
<tr>
<td><%= idx + 1 %>. <%= tournament_team_chip(row.participant) %></td>
<td><%= row.played %></td>
<td><%= row.won %></td>
<td><%= row.lost %></td>
<td><strong><%= row.points %></strong></td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% visible_rounds.each do |round| %>
<h3 class="tournament-board__day"><%= round.name %></h3>
<div class="tournament-table-wrap">
<table class="data tournament-board">
<thead>
<tr>
<th><%= t("tournaments.hub.datetime") %></th>
<th><%= t("tournaments.hub.court") %></th>
<th><%= t("tournaments.hub.stream_plan_match") %></th>
<th><%= t("tournaments.page.score") %></th>
<th><%= t("tournaments.hub.stream_plan_live_col") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @matches_by_round[round.id].each do |match| %>
<%= render "public/tournament_pages/board_row", match: match %>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% if @groups.none? && visible_rounds.none? %>
<p class="muted"><%= t("tournaments.page.bracket_empty") %></p>
<% end %>
</section>
@@ -0,0 +1,20 @@
<% content_for :title, t("tournaments.directory.meta_title") %>
<% content_for :meta_description, t("tournaments.directory.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_tournament_pages_path) %>
<div class="wrap team-directory">
<h1><%= t("tournaments.directory.heading") %></h1>
<p class="results-hint"><%= t("tournaments.directory.hint") %></p>
<% if @tournaments.any? %>
<div class="team-directory-grid">
<% @tournaments.each do |tournament| %>
<%= render "public/tournament_pages/directory_card", tournament: tournament %>
<% end %>
</div>
<% else %>
<div class="empty-state empty-state--soft">
<p><strong><%= t("tournaments.directory.empty") %></strong></p>
</div>
<% end %>
</div>
@@ -0,0 +1,86 @@
<% content_for :title, t("tournaments.page.title", name: @tournament.name, club: @club.name) %>
<% content_for :robots, @tournament.published? ? "index, follow" : "noindex, nofollow" %>
<% content_for :canonical_url, seo_absolute_url(public_tournament_page_path(@tournament.slug)) if @tournament.published? %>
<div class="wrap team-public-page tournament-public">
<p class="results-hint" style="margin-bottom:8px">
<%= link_to t("tournaments.page.back_to_list"), public_tournament_pages_path, class: "back-link" %>
</p>
<header class="roster-hero card team-public-hero tournament-public-hero" style="--club-primary:<%= @tournament.effective_primary_color %>;--club-secondary:<%= @tournament.effective_secondary_color %>">
<% logo = @tournament.effective_logo_url %>
<% if logo.present? %>
<%= image_tag logo, alt: "", class: "tournament-public-hero__logo", width: 56, height: 56 %>
<% else %>
<div class="tournament-public-hero__logo tournament-public-hero__logo--fallback" aria-hidden="true">
<%= @tournament.name.to_s.first %>
</div>
<% end %>
<div class="roster-hero__intro">
<p class="roster-hero__club"><%= @club.name %></p>
<h1 class="roster-hero__title"><%= @tournament.name %></h1>
<p class="team-public-meta">
<%= @tournament.sport_label %>
· <%= l(@tournament.starts_on) %> <%= l(@tournament.ends_on) %>
<% if @tournament.venue.present? %> · <%= @tournament.venue %><% end %>
</p>
<% if @owner_preview %>
<p class="muted" style="margin:6px 0 0"><%= t("tournaments.page.draft_banner") %></p>
<% end %>
</div>
<% if @owner_manage %>
<%= link_to t("tournaments.index.open"), public_tournament_path(@tournament), class: "btn btn-secondary tournament-public-hero__manage" %>
<% end %>
</header>
<% if @live_sessions.any? %>
<section class="team-public-section" aria-labelledby="tournament-live-heading">
<h2 id="tournament-live-heading" class="section-heading"><%= t("tournaments.page.section_live") %></h2>
<div class="live-grid">
<% @live_sessions.each do |session| %>
<% match = session.match %>
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
<article class="live-card">
<%= live_match_card_heading(match, link_team: false) %>
<p class="meta">
<% if match.court_or_location.present? %><%= match.court_or_location %> · <% end %>
Match Live TV
</p>
<% if session.score_state %>
<p class="card-score">
<span class="card-sets"><%= live_score_sets_label(session.score_state, match) %></span>
<% if live_score_partials_label(session.score_state).present? %>
<span class="card-partials"><%= t("score.partials_prefix", value: live_score_partials_label(session.score_state)) %></span>
<% end %>
<span class="card-points"><%= session.score_state.home_points %> - <%= session.score_state.away_points %></span>
</p>
<% end %>
<div class="badges">
<% if on_air %>
<span class="badge badge-on-air"><%= t("live.index.badge_on_air") %></span>
<% elsif session.paused? %>
<span class="badge badge-connecting"><%= t("live.index.badge_paused") %></span>
<% else %>
<span class="badge badge-live"><%= t("live.index.badge_live") %></span>
<% end %>
</div>
<% watch = tournament_public_watch_target(session) %>
<% if watch %>
<% url, label, html_opts = watch %>
<%= link_to label, url, { class: "btn-watch" }.merge(html_opts) %>
<% end %>
</article>
<% end %>
</div>
</section>
<% end %>
<nav class="tournament-public-tabs" aria-label="<%= t("tournaments.page.tabs_label") %>">
<% %w[risultati tabellone].each do |tab| %>
<%= link_to t("tournaments.page.tabs.#{tab}"), public_tournament_page_path(@tournament.slug, tab: tab),
class: "btn #{@tab == tab ? 'btn-primary' : 'btn-secondary'}" %>
<% end %>
</nav>
<%= render "public/tournament_pages/tab_#{@tab}" %>
</div>
@@ -0,0 +1,10 @@
<% participant = match.public_send("#{side}_participant") %>
<% if @writable && !tournament_match_sides_locked?(match) %>
<%= select_tag "match[#{side}_participant_id]",
options_from_collection_for_select(@participants, :id, :name, participant&.id),
include_blank: tournament_side_blank_label(match, side),
form: form_id,
class: "tournament-match-form__team" %>
<% else %>
<%= tournament_team_chip(participant, name: match.public_send("#{side}_display_name")) %>
<% end %>
@@ -0,0 +1,64 @@
<% assignments = match.broadcast_assignments.to_a %>
<% pending = tournament_pending_invites_for(match) %>
<% tab = local_assigns.fetch(:return_tab, "dirette") %>
<% invite = local_assigns.fetch(:allow_invite, false) %>
<td class="tournament-streaming-cell">
<% assignments.each do |assignment| %>
<% label = tournament_streaming_operator_label(assignment.user) %>
<div class="tournament-streaming-chip tournament-streaming-chip--assigned">
<i class="fa-solid fa-video" aria-hidden="true"></i>
<span class="tournament-streaming-chip__name"><%= label %></span>
<% if writable %>
<%= button_to public_tournament_assignment_path(@tournament, assignment),
method: :delete,
class: "tournament-streaming-chip__action",
title: t("tournaments.hub.streaming_revoke"),
form: {
class: "tournament-streaming-chip__form",
data: { turbo_confirm: t("tournaments.hub.streaming_revoke_confirm", name: label) }
} do %>
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden"><%= t("tournaments.hub.streaming_revoke") %></span>
<% end %>
<% end %>
</div>
<% end %>
<% pending.each do |invitation| %>
<div class="tournament-streaming-chip tournament-streaming-chip--pending">
<i class="fa-regular fa-clock" aria-hidden="true"></i>
<span class="tournament-streaming-chip__name"><%= invitation.email %></span>
<% if writable %>
<%= button_to public_tournament_invitation_path(@tournament, invitation),
method: :delete,
params: { tab: tab },
class: "tournament-streaming-chip__action",
title: t("team.streaming_staff.cancel_invitation"),
form: {
class: "tournament-streaming-chip__form",
data: { turbo_confirm: tournament_invite_cancel_confirm(invitation) }
} do %>
<i class="fa-solid fa-xmark" aria-hidden="true"></i>
<span class="visually-hidden"><%= t("team.streaming_staff.cancel_invitation") %></span>
<% end %>
<% end %>
</div>
<% end %>
<% if writable && invite && assignments.empty? && pending.empty? %>
<%= form_with url: public_tournament_invitations_path(@tournament), method: :post, html: { class: "tournament-streaming-form" } do %>
<%= hidden_field_tag :from, "calendar" %>
<%= hidden_field_tag :tab, tab %>
<%= hidden_field_tag "match_ids[]", match.id %>
<%= email_field_tag :email, nil,
required: true,
autocomplete: "email",
placeholder: t("tournaments.hub.invite_email"),
class: "tournament-streaming-form__email",
id: "stream_email_#{match.id}" %>
<%= submit_tag t("tournaments.hub.streaming_invite"), class: "btn btn-secondary tournament-match-form__save" %>
<% end %>
<% elsif assignments.empty? && pending.empty? %>
<span class="tournament-streaming-empty"></span>
<% end %>
</td>
@@ -0,0 +1,157 @@
<div class="card">
<% if @writable && @tournament.uses_groups? %>
<p class="tournament-hub-hint"><%= t("tournaments.hub.generate_hint") %></p>
<%= button_to t("tournaments.hub.generate_group_matches"),
public_generate_group_matches_tournament_path(@tournament),
method: :post, class: "btn btn-secondary" %>
<% end %>
<% if @matches.any? %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p>
<div class="tournament-match-list tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>">
<% @matches.each do |match| %>
<% form_id = "match-edit-#{match.id}" %>
<% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %>
<%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %>
<%= hidden_field_tag :tab, @tab, form: form_id %>
<% end %>
<article class="tournament-match-row">
<% if @writable %>
<div class="tournament-match-row__field">
<label for="match_at_<%= match.id %>"><%= t("tournaments.hub.datetime") %></label>
<%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at),
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
</div>
<div class="tournament-match-row__field">
<label for="match_court_<%= match.id %>"><%= t("tournaments.hub.court") %></label>
<%= select_tag "match[court]", options_for_select(courts, match.court),
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
</div>
<div class="tournament-match-row__field tournament-match-row__field--phase">
<span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
<p class="tournament-match-row__phase"><%= tournament_phase_label(match) %></p>
</div>
<div class="tournament-match-row__sides">
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.home") %></span>
<div data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></div>
</div>
<div class="tournament-match-swap-col">
<%= button_to public_swap_tournament_match_path(@tournament, match), method: :post,
class: "tournament-match-swap__btn",
title: t("tournaments.hub.swap_sides"),
form: { class: "tournament-match-swap__form", data: { turbo: false } } do %>
<i class="fa-solid fa-right-left" aria-hidden="true"></i>
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
<% end %>
</div>
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<div data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></div>
</div>
<div class="tournament-match-row__score">
<span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
<% if match.result_recorded? %>
<p data-swap-played-score><%= match.home_score %><%= match.away_score %></p>
<% else %>
<span class="tournament-match-form__score">
<%= number_field_tag :home_score, match.home_score, id: "home_score_#{match.id}", form: form_id, data: { swap_score: "home" } %>
<span></span>
<%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %>
</span>
<% end %>
</div>
</div>
<div class="tournament-match-form__actions tournament-match-row__actions">
<%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %>
<% if match.deletable? %>
<%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match),
method: :delete, class: "btn btn-secondary tournament-match-form__save",
form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %>
<% end %>
</div>
<% else %>
<div class="tournament-match-row__field">
<span class="tournament-match-row__label"><%= t("tournaments.hub.datetime") %></span>
<p class="tournament-match-row__phase"><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></p>
</div>
<div class="tournament-match-row__field">
<span class="tournament-match-row__label"><%= t("tournaments.hub.court") %></span>
<p class="tournament-match-row__phase"><%= match.court || "—" %></p>
</div>
<div class="tournament-match-row__field tournament-match-row__field--phase">
<span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
<p class="tournament-match-row__phase"><%= tournament_phase_label(match) %></p>
</div>
<div class="tournament-match-row__sides">
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.home") %></span>
<%= tournament_team_chip(match.home_participant, name: match.home_display_name) %>
</div>
<div class="tournament-match-swap-col" aria-hidden="true"></div>
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<%= tournament_team_chip(match.away_participant, name: match.away_display_name) %>
</div>
<div class="tournament-match-row__score">
<span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
<p>
<% if match.home_score.present? %>
<%= match.home_score %><%= match.away_score %>
<% else %>
<% end %>
</p>
</div>
</div>
<% end %>
</article>
<% end %>
</div>
<% else %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p>
<% end %>
<% if @writable %>
<details class="tournament-schedule-extra">
<summary><%= t("tournaments.hub.schedule_another") %></summary>
<%= form_with url: public_tournament_matches_path(@tournament), method: :post, html: { class: "tournament-form", style: "margin-top:16px" } do %>
<div class="tournament-form__field">
<%= label_tag "match[home_participant_id]", t("tournaments.hub.home") %>
<%= select_tag "match[home_participant_id]",
options_from_collection_for_select(@participants, :id, :name), include_blank: t("tournaments.tbd") %>
</div>
<div class="tournament-form__field">
<%= label_tag "match[away_participant_id]", t("tournaments.hub.away") %>
<%= select_tag "match[away_participant_id]",
options_from_collection_for_select(@participants, :id, :name), include_blank: t("tournaments.tbd") %>
</div>
<div class="tournament-form__field">
<%= label_tag "match[court]", t("tournaments.hub.court") %>
<%= select_tag "match[court]", options_for_select(@tournament.court_list) %>
</div>
<div class="tournament-form__field">
<%= label_tag "match[scheduled_at]", t("tournaments.hub.datetime") %>
<%= datetime_local_field_tag "match[scheduled_at]", nil, required: true %>
</div>
<% if @groups.any? %>
<div class="tournament-form__field">
<%= label_tag "match[tournament_group_id]", t("tournaments.hub.group") %>
<%= select_tag "match[tournament_group_id]",
options_from_collection_for_select(@groups, :id, :name), include_blank: true %>
</div>
<% end %>
<% if @rounds.any? %>
<div class="tournament-form__field">
<%= label_tag "match[tournament_round_id]", t("tournaments.hub.round") %>
<%= select_tag "match[tournament_round_id]",
options_from_collection_for_select(@rounds, :id, :name), include_blank: t("tournaments.hub.no_round") %>
</div>
<% end %>
<div class="tournament-form__actions">
<%= submit_tag t("tournaments.hub.schedule_match"), class: "btn btn-primary" %>
</div>
<% end %>
</details>
<% end %>
</div>
@@ -0,0 +1,170 @@
<% coverage = tournament_streaming_coverage(@matches) %>
<% live_counts = tournament_streaming_live_counts(@matches) %>
<div class="tournament-stream-plan">
<div class="card">
<div class="tournament-stream-plan__header">
<div>
<h2 class="tournament-stream-plan__title"><%= t("tournaments.hub.stream_plan_title") %></h2>
<p class="tournament-hub-hint"><%= t("tournaments.hub.stream_plan_hint") %></p>
</div>
<% if @writable && @matches.any? %>
<a class="btn btn-secondary tournament-stream-plan__jump" href="#invito-operatore"><%= t("tournaments.hub.stream_plan_invite_title") %></a>
<% end %>
</div>
<% if @matches.any? %>
<div class="tournament-stream-plan__stats">
<% %i[live waiting_time waiting_operator ended uncovered].each do |state| %>
<% count = live_counts[state].to_i %>
<% next if count.zero? %>
<span class="tournament-stream-plan__stat tournament-stream-plan__stat--<%= state %>">
<%= t("tournaments.hub.stream_plan_live_count.#{state}", count: count) %>
</span>
<% end %>
<% if @writable && coverage[:open].positive? %>
<button type="button" class="btn btn-secondary tournament-stream-plan__select" data-select-uncovered="tournament-stream-invite">
<%= t("tournaments.hub.stream_plan_select_open") %>
</button>
<% end %>
</div>
<% tournament_matches_grouped_by_date(@matches).each do |date, matches| %>
<h3 class="tournament-stream-plan__day">
<%= date ? l(date, format: :long) : t("tournaments.hub.stream_plan_unscheduled") %>
</h3>
<div class="tournament-table-wrap">
<table class="data tournament-stream-plan__table">
<thead>
<tr>
<% if @writable %><th class="tournament-stream-plan__check-col"></th><% end %>
<th><%= t("tournaments.hub.datetime") %></th>
<th><%= t("tournaments.hub.court") %></th>
<th><%= t("tournaments.hub.stream_plan_match") %></th>
<th><%= t("tournaments.hub.stream_plan_live_col") %></th>
<th><%= t("tournaments.hub.stream_plan_operator") %></th>
</tr>
</thead>
<tbody>
<% matches.each do |match| %>
<% state = tournament_broadcast_live_state(match) %>
<% uncovered = state == :uncovered %>
<tr class="tournament-stream-plan__row tournament-stream-plan__row--<%= state %>">
<% if @writable %>
<td class="tournament-stream-plan__check-col">
<% if uncovered %>
<label class="tournament-stream-plan__check">
<%= check_box_tag "match_ids[]", match.id, false,
id: "plan_match_#{match.id}",
form: "tournament-stream-invite",
data: { uncovered_match: true } %>
<span class="visually-hidden"><%= t("tournaments.hub.stream_plan_select_match") %></span>
</label>
<% end %>
</td>
<% end %>
<td><%= match.scheduled_at ? match.scheduled_at.in_time_zone.strftime("%H:%M") : "—" %></td>
<td><%= match.court.presence || "—" %></td>
<td>
<div class="tournament-stream-plan__matchup">
<%= tournament_team_chip(match.home_participant, name: match.home_display_name) %>
<span class="tournament-stream-plan__vs"></span>
<%= tournament_team_chip(match.away_participant, name: match.away_display_name) %>
</div>
</td>
<td>
<span class="tournament-live-status tournament-live-status--<%= state %>">
<span class="tournament-live-status__dot" aria-hidden="true"></span>
<%= t("tournaments.hub.stream_plan_live.#{state}") %>
</span>
</td>
<%= render "public/tournaments/match_streaming", match: match, writable: @writable, allow_invite: false, return_tab: "dirette" %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
<% else %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p>
<% end %>
</div>
<% if @writable %>
<div class="card tournament-stream-plan__invite-card" id="invito-operatore">
<h2 class="tournament-stream-plan__title"><%= t("tournaments.hub.stream_plan_invite_title") %></h2>
<p class="tournament-hub-hint"><%= t("tournaments.hub.stream_plan_invite_hint") %></p>
<%= form_with url: public_tournament_invitations_path(@tournament), method: :post,
html: { id: "tournament-stream-invite", class: "tournament-form tournament-invite-form tournament-stream-plan__invite" } do %>
<%= hidden_field_tag :tab, "dirette" %>
<div class="tournament-form__field">
<%= label_tag :email, t("tournaments.hub.invite_email") %>
<%= email_field_tag :email, @tournament.invite_draft_email, required: true, autocomplete: "email" %>
</div>
<div class="tournament-form__field">
<%= label_tag :note, t("tournaments.hub.invite_note") %>
<%= text_area_tag :note, @tournament.invite_draft_note, rows: 2, maxlength: 2000, data: { invite_note: true },
placeholder: t("tournaments.hub.invite_note_placeholder") %>
</div>
<div class="tournament-form__field">
<%= label_tag :court, t("tournaments.hub.court") %>
<%= select_tag :court, options_for_select(@tournament.court_list) %>
</div>
<div class="tournament-form__field">
<%= label_tag :on_date, t("tournaments.hub.datetime") %>
<%= date_field_tag :on_date, @tournament.starts_on %>
</div>
<p class="tournament-hub-hint tournament-form__full"><%= t("tournaments.hub.stream_plan_court_day_hint") %></p>
<div class="tournament-form__full tournament-stream-plan__mail">
<h3 class="tournament-stream-plan__mail-title"><%= t("tournaments.hub.invite_email_body") %></h3>
<p class="tournament-hub-hint"><%= t("tournaments.hub.invite_email_hint") %></p>
<% if @tournament.invite_draft_saved_at.present? %>
<p class="tournament-hub-hint"><%= t("tournaments.hub.invite_draft_hint", time: l(@tournament.invite_draft_saved_at, format: :short)) %></p>
<% end %>
<div class="invite-editor"
data-invite-editor
data-upload-url="<%= public_tournament_invite_images_path(@tournament) %>"
data-msg-type="<%= t("tournaments.hub.invite_image_invalid") %>"
data-msg-size="<%= t("tournaments.hub.invite_image_too_big") %>"
data-msg-uploading="<%= t("tournaments.hub.invite_image_uploading") %>"
data-msg-failed="<%= t("tournaments.hub.invite_image_failed") %>"
data-msg-link="<%= t("tournaments.hub.invite_editor_link_prompt") %>">
<div class="invite-editor__toolbar" role="toolbar" aria-label="<%= t("tournaments.hub.invite_editor_toolbar") %>">
<button type="button" class="invite-editor__btn" data-invite-cmd="bold" aria-label="<%= t("tournaments.hub.invite_editor_bold") %>"><i class="fa-solid fa-bold" aria-hidden="true"></i></button>
<button type="button" class="invite-editor__btn" data-invite-cmd="italic" aria-label="<%= t("tournaments.hub.invite_editor_italic") %>"><i class="fa-solid fa-italic" aria-hidden="true"></i></button>
<button type="button" class="invite-editor__btn" data-invite-cmd="underline" aria-label="<%= t("tournaments.hub.invite_editor_underline") %>"><i class="fa-solid fa-underline" aria-hidden="true"></i></button>
<button type="button" class="invite-editor__btn" data-invite-cmd="insertUnorderedList" aria-label="<%= t("tournaments.hub.invite_editor_ul") %>"><i class="fa-solid fa-list-ul" aria-hidden="true"></i></button>
<button type="button" class="invite-editor__btn" data-invite-cmd="insertOrderedList" aria-label="<%= t("tournaments.hub.invite_editor_ol") %>"><i class="fa-solid fa-list-ol" aria-hidden="true"></i></button>
<button type="button" class="invite-editor__btn" data-invite-cmd="createLink" aria-label="<%= t("tournaments.hub.invite_editor_link") %>"><i class="fa-solid fa-link" aria-hidden="true"></i></button>
<button type="button" class="invite-editor__btn" data-invite-cmd="image" aria-label="<%= t("tournaments.hub.invite_editor_image") %>"><i class="fa-solid fa-image" aria-hidden="true"></i></button>
<input type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden data-invite-file>
</div>
<div class="invite-editor__frame">
<div class="invite-editor__surface" contenteditable="true" role="textbox" aria-multiline="true">
<%= raw tournament_invite_editor_html(@tournament, current_user) %>
</div>
<span class="invite-editor__handle" hidden></span>
</div>
<p class="invite-editor__status" data-invite-status hidden></p>
</div>
<%= hidden_field_tag :email_html, "", id: "invite-email-html", data: { invite_html: true } %>
</div>
<div class="tournament-form__actions">
<%= button_tag t("tournaments.hub.invite_save"), type: "submit", name: "intent", value: "save",
formnovalidate: true, class: "btn btn-secondary" %>
<%= submit_tag t("tournaments.hub.stream_plan_invite_selected"), class: "btn btn-primary" %>
<%= button_tag t("tournaments.hub.stream_plan_invite_court"), type: "submit", name: "whole_court", value: "1", class: "btn btn-secondary" %>
</div>
<% end %>
<% if flash[:invite_url].present? %>
<div class="team-invite-link" id="invito-generato" style="margin-top:16px">
<p><%= t("team.invite.share_hint") %></p>
<code id="tournament-invite-url"><%= flash[:invite_url] %></code>
</div>
<% end %>
</div>
<% end %>
</div>
@@ -0,0 +1,100 @@
<div class="card">
<p class="tournament-hub-hint"><%= t("tournaments.hub.squadre_edit_hint") %></p>
<% if @writable %>
<%= form_with url: public_tournament_participants_path(@tournament), method: :post, html: { class: "tournament-form" } do %>
<div class="tournament-form__field">
<%= label_tag "tournament_participant[name]", t("tournaments.hub.add_participant") %>
<%= text_field_tag "tournament_participant[name]", nil, required: true, placeholder: t("tournaments.hub.participant_name") %>
</div>
<% if @groups.any? %>
<div class="tournament-form__field">
<%= label_tag "tournament_participant[group_id]", t("tournaments.hub.participant_group") %>
<%= select_tag "tournament_participant[group_id]",
options_for_select([[t("tournaments.hub.no_group"), ""]] + @groups.map { |g| [g.name, g.id] }) %>
</div>
<% end %>
<div class="tournament-form__actions">
<%= submit_tag t("tournaments.hub.add_participant"), class: "btn btn-primary" %>
</div>
<% end %>
<% end %>
<% if @participants.any? %>
<div class="tournament-table-wrap">
<table class="data tournament-squad-table">
<thead>
<tr>
<th><%= t("tournaments.hub.logo") %></th>
<th><%= t("tournaments.hub.participant_name") %></th>
<th><%= t("tournaments.hub.participant_group") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @participants.each do |participant| %>
<% form_id = "participant-edit-#{participant.id}" %>
<% logo = participant.effective_logo_url %>
<%= form_with url: public_tournament_participant_path(@tournament, participant),
method: :patch,
multipart: true,
html: { id: form_id, class: "visually-hidden" } do %>
<% end %>
<tr>
<td>
<div class="tournament-squad-logo">
<div class="tournament-squad-logo__preview" aria-hidden="true">
<% if logo.present? %>
<%= image_tag logo, alt: "" %>
<% else %>
<span class="tournament-squad-logo__empty">+</span>
<% end %>
</div>
<% if @writable %>
<label class="btn btn-secondary tournament-squad-logo__btn">
<%= t("tournaments.hub.logo_file") %>
<%= file_field_tag "tournament_participant[logo_file]",
id: "logo_file_#{participant.id}",
form: form_id,
accept: "image/png,image/jpeg,image/webp",
onchange: "this.form.requestSubmit()" %>
</label>
<% end %>
</div>
</td>
<td>
<% if @writable %>
<%= text_field_tag "tournament_participant[name]", participant.name,
id: "participant_name_#{participant.id}",
form: form_id,
required: true,
class: "tournament-squad-name" %>
<% else %>
<%= tournament_team_chip(participant) %>
<% end %>
</td>
<td>
<% if @writable && @groups.any? %>
<%= select_tag "tournament_participant[group_id]",
options_for_select([[t("tournaments.hub.no_group"), ""]] + @groups.map { |g| [g.name, g.id] }, participant.group_id),
id: "participant_group_#{participant.id}",
form: form_id,
class: "tournament-squad-group" %>
<% else %>
<%= participant.group&.name || "—" %>
<% end %>
</td>
<td class="tournament-match-form__actions">
<% if @writable %>
<%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %>
<%= button_to t("matches.index.delete"), public_tournament_participant_path(@tournament, participant),
method: :delete, class: "btn btn-secondary tournament-match-form__save",
form: { data: { turbo_confirm: participant.name } } %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</div>
@@ -0,0 +1,34 @@
<div class="card">
<p style="color:#888"><%= t("tournaments.form.formats.#{@tournament.format_kind}") %></p>
<% if @tournament.uses_groups? %>
<h2><%= t("tournaments.hub.tabs.struttura") %></h2>
<ul>
<% @groups.each do |group| %>
<li style="margin-bottom:8px">
<%= group.name %>
<% if @writable %>
<%= button_to t("matches.index.delete"), public_tournament_group_path(@tournament, group),
method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem;display:inline",
form: { data: { turbo_confirm: group.name }, style: "display:inline" } %>
<% end %>
</li>
<% end %>
</ul>
<% if @writable %>
<%= form_with url: public_tournament_groups_path(@tournament), method: :post do %>
<%= text_field_tag "tournament_group[name]", nil, placeholder: t("tournaments.hub.add_group") %>
<%= submit_tag t("tournaments.hub.add_group"), class: "btn btn-secondary" %>
<% end %>
<% end %>
<% end %>
<% if @tournament.uses_knockout? %>
<h2 style="margin-top:20px"><%= t("tournaments.hub.tabs.tabellone") %></h2>
<ul>
<% @rounds.each do |round| %>
<li><%= round.name %></li>
<% end %>
</ul>
<% end %>
</div>
@@ -0,0 +1,113 @@
<div class="card">
<% if @writable && @tournament.uses_knockout? %>
<div class="tournament-knockout-help">
<h2 style="margin-top:0"><%= t("tournaments.hub.knockout_how_title") %></h2>
<ol class="tournament-knockout-help__steps">
<li><%= t("tournaments.hub.knockout_how_groups") %></li>
<li><%= t("tournaments.hub.knockout_how_seed") %></li>
<li><%= t("tournaments.hub.knockout_how_advance") %></li>
</ol>
<% if @tournament.uses_groups? %>
<%= button_to t("tournaments.hub.propose_knockout"),
public_propose_knockout_tournament_path(@tournament),
method: :post, class: "btn btn-primary" %>
<% end %>
</div>
<% end %>
<% @groups.each do |group| %>
<h2 style="margin-top:20px"><%= group.name %><%= t("tournaments.hub.standings") %></h2>
<% rows = @standings_by_group[group] %>
<% if rows.any? %>
<table class="data">
<thead>
<tr>
<th></th>
<th><%= t("tournaments.hub.played") %></th>
<th><%= t("tournaments.hub.won") %></th>
<th><%= t("tournaments.hub.lost") %></th>
<th><%= t("tournaments.hub.points") %></th>
</tr>
</thead>
<tbody>
<% rows.each_with_index do |row, idx| %>
<tr>
<td><%= idx + 1 %>. <%= tournament_team_chip(row.participant) %></td>
<td><%= row.played %></td>
<td><%= row.won %></td>
<td><%= row.lost %></td>
<td><strong><%= row.points %></strong></td>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% end %>
<% @rounds.each do |round| %>
<h2 style="margin-top:24px"><%= round.name %></h2>
<% round_matches = @matches.select { |m| m.tournament_round_id == round.id } %>
<% if round_matches.any? %>
<div class="tournament-table-wrap">
<table class="data tournament-match-table">
<thead>
<tr>
<th><%= t("tournaments.hub.datetime") %></th>
<th><%= t("tournaments.hub.home") %></th>
<th><%= t("tournaments.hub.away") %></th>
<th><%= t("tournaments.hub.result") %></th>
<% if @writable %><th></th><% end %>
</tr>
</thead>
<tbody>
<% round_matches.each do |match| %>
<% form_id = "ko-edit-#{match.id}" %>
<%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %>
<%= hidden_field_tag :tab, "tabellone", form: form_id %>
<%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at),
id: "ko_at_#{match.id}", form: form_id %>
<% end %>
<tr>
<td><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></td>
<td>
<%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %>
<% src = tournament_side_source_label(match, :home) %>
<% if src.present? && match.home_participant_id.blank? %>
<div class="tournament-knockout-source"><%= src %></div>
<% end %>
</td>
<td>
<%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %>
<% src = tournament_side_source_label(match, :away) %>
<% if src.present? && match.away_participant_id.blank? %>
<div class="tournament-knockout-source"><%= src %></div>
<% end %>
</td>
<td>
<% if tournament_match_sides_locked?(match) %>
<%= match.home_score %><%= match.away_score %>
<% elsif @writable %>
<span class="tournament-match-form__score">
<%= number_field_tag :home_score, match.home_score, id: "ko_home_score_#{match.id}", form: form_id %>
<span></span>
<%= number_field_tag :away_score, match.away_score, id: "ko_away_score_#{match.id}", form: form_id %>
</span>
<% else %>
<% end %>
</td>
<% if @writable %>
<td class="tournament-match-form__actions">
<%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %>
</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="tournament-hub-hint"><%= t("tournaments.hub.knockout_round_empty") %></p>
<% end %>
<% end %>
</div>
@@ -0,0 +1,61 @@
<% content_for :title, t("tournaments.index.title", name: @club.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap wrap--portal tournament-hub" style="padding-top:20px">
<nav class="team-dashboard-nav">
<%= link_to "← #{@club.name}", public_club_path(@club), class: "team-dashboard-nav__club" %>
</nav>
<header style="margin:16px 0 20px;display:flex;flex-wrap:wrap;gap:12px;align-items:flex-start;justify-content:space-between">
<div>
<h1><%= t("tournaments.index.heading") %></h1>
<p style="color:#888;margin:8px 0 0"><%= t("tournaments.index.lead") %></p>
</div>
<% if @can_create %>
<%= link_to t("tournaments.index.new"), public_new_club_tournament_path(@club), class: "btn btn-primary" %>
<% end %>
</header>
<% unless @can_create %>
<div class="flash alert"><%= t("tournaments.index.upgrade") %> <%= link_to t("club.dashboard.subscription"), public_club_billing_path(@club) %></div>
<% end %>
<div class="card">
<% if @tournaments.any? %>
<div class="tournament-table-wrap">
<table class="data">
<thead>
<tr>
<th><%= t("tournaments.index.col_name") %></th>
<th><%= t("tournaments.index.col_dates") %></th>
<th><%= t("tournaments.index.col_status") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @tournaments.each do |tournament| %>
<tr>
<td><strong><%= tournament.name %></strong></td>
<td><%= l(tournament.starts_on) %> <%= l(tournament.ends_on) %></td>
<td><%= t("tournaments.status.#{tournament.status}") %></td>
<td>
<div class="tournament-index-actions">
<%= link_to t("tournaments.index.open"), public_tournament_path(tournament), class: "btn btn-secondary", style: "padding:8px 14px;font-size:0.9rem" %>
<% if tournament.published? %>
<%= link_to t("tournaments.hub.public_page"), public_tournament_page_path(tournament.slug), class: "btn btn-secondary", style: "padding:8px 14px;font-size:0.9rem", target: "_blank", rel: "noopener" %>
<% end %>
<%= button_to t("tournaments.index.delete"), public_tournament_path(tournament), method: :delete,
class: "btn btn-secondary", style: "padding:8px 14px;font-size:0.9rem",
form: { data: { turbo_confirm: t("tournaments.hub.delete_confirm", name: tournament.name), confirm_kind: "delete" } } %>
</div>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p style="color:#888;margin:0"><%= t("tournaments.index.empty") %></p>
<% end %>
</div>
</div>
@@ -0,0 +1,79 @@
<% content_for :title, t("tournaments.new.title", name: @club.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap wrap--portal tournament-new" style="padding-top:20px">
<nav class="team-dashboard-nav">
<%= link_to "← #{t("tournaments.index.heading")}", public_club_tournaments_path(@club), class: "team-dashboard-nav__club" %>
</nav>
<h1><%= t("tournaments.new.heading") %></h1>
<p class="tournament-new__lead"><%= t("tournaments.index.lead") %></p>
<div class="card" style="margin-top:16px">
<%= form_with model: @tournament, url: public_club_tournaments_path(@club), method: :post, html: { class: "tournament-form", multipart: true, data: { tournament_form: true } } do %>
<div class="tournament-form__field tournament-form__full">
<%= label_tag "tournament[name]", t("tournaments.form.name") %>
<%= text_field_tag "tournament[name]", @tournament.name, required: true %>
</div>
<div class="tournament-form__field">
<%= label_tag "tournament[sport_key]", t("tournaments.form.sport") %>
<%= select_tag "tournament[sport_key]",
options_for_select(Sports::Catalog.as_api_list.map { |s| [s[:label], s[:key]] }, @tournament.sport_key) %>
</div>
<div class="tournament-form__field">
<%= label_tag "tournament[venue]", t("tournaments.form.venue") %>
<%= text_field_tag "tournament[venue]", @tournament.venue %>
</div>
<div class="tournament-form__field">
<%= label_tag "tournament[starts_on]", t("tournaments.form.starts_on") %>
<%= date_field_tag "tournament[starts_on]", @tournament.starts_on, required: true %>
</div>
<div class="tournament-form__field">
<%= label_tag "tournament[ends_on]", t("tournaments.form.ends_on") %>
<%= date_field_tag "tournament[ends_on]", @tournament.ends_on, required: true %>
</div>
<div class="tournament-form__field">
<%= label_tag "tournament[format_kind]", t("tournaments.form.format") %>
<%= select_tag "tournament[format_kind]",
options_for_select(Tournament::FORMAT_KINDS.map { |k| [t("tournaments.form.formats.#{k}"), k] }, @tournament.format_kind),
data: { tournament_format: true } %>
</div>
<div class="tournament-form__field" data-knockout-field>
<%= label_tag "tournament[knockout_size]", t("tournaments.form.knockout_size") %>
<%= select_tag "tournament[knockout_size]", options_for_select([2, 4, 8, 16], @tournament.knockout_size || 4) %>
</div>
<div class="tournament-form__field tournament-form__full">
<%= label_tag "tournament[logo_file]", t("tournaments.form.logo") %>
<%= file_field_tag "tournament[logo_file]", accept: "image/png,image/jpeg,image/webp" %>
<p class="tournament-hub-hint" style="margin:6px 0 0"><%= t("tournaments.form.logo_hint") %></p>
</div>
<div class="tournament-form__field tournament-form__full">
<%= label_tag "tournament[courts]", t("tournaments.form.courts") %>
<%= text_area_tag "tournament[courts]", Array(@tournament.courts).join("\n"), rows: 3 %>
</div>
<div class="tournament-form__actions">
<%= submit_tag t("tournaments.new.submit"), class: "btn btn-primary" %>
</div>
<% end %>
</div>
</div>
<script>
document.querySelectorAll("[data-tournament-form]").forEach(function (form) {
var format = form.querySelector("[data-tournament-format]");
var knockout = form.querySelector("[data-knockout-field]");
if (!format || !knockout) return;
var sync = function () {
knockout.hidden = !(format.value === "knockout" || format.value === "mixed");
};
format.addEventListener("change", sync);
sync();
});
</script>
@@ -0,0 +1,73 @@
<% content_for :title, t("tournaments.hub.title", name: @tournament.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap wrap--portal tournament-hub" style="padding-top:20px">
<nav class="team-dashboard-nav">
<%= link_to "← #{t("tournaments.index.heading")}", public_club_tournaments_path(@club), class: "team-dashboard-nav__club" %>
</nav>
<header class="tournament-hub-header">
<div class="tournament-hub-header__identity">
<% logo = @tournament.effective_logo_url %>
<div class="tournament-hub-logo">
<div class="tournament-hub-logo__preview" aria-hidden="true">
<% if logo.present? %>
<%= image_tag logo, alt: "" %>
<% else %>
<span class="tournament-hub-logo__empty"><%= @tournament.name.to_s.first %></span>
<% end %>
</div>
<% if @writable %>
<%= form_with model: @tournament, url: public_tournament_path(@tournament), method: :patch,
html: { class: "tournament-hub-logo__form", multipart: true } do %>
<label class="btn btn-secondary tournament-hub-logo__btn">
<%= t("tournaments.hub.logo_file") %>
<%= file_field_tag "tournament[logo_file]", accept: "image/png,image/jpeg,image/webp", onchange: "this.form.requestSubmit()" %>
</label>
<% end %>
<% end %>
</div>
<div class="tournament-hub-header__text">
<h1><%= @tournament.name %></h1>
<p>
<%= @tournament.sport_label %>
· <%= l(@tournament.starts_on) %> <%= l(@tournament.ends_on) %>
· <%= t("tournaments.status.#{@tournament.status}") %>
· <%= t("tournaments.form.formats.#{@tournament.format_kind}") %>
</p>
<% if @writable %>
<p class="tournament-hub-hint" style="margin:4px 0 0"><%= t("tournaments.hub.tournament_logo_hint") %></p>
<% end %>
</div>
</div>
<div class="tournament-hub-header__actions">
<% if @tournament.published? %>
<%= link_to t("tournaments.hub.public_page"), public_tournament_page_path(@tournament.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
<% if @writable %>
<%= button_to t("tournaments.hub.unpublish"), public_unpublish_tournament_path(@tournament), method: :post, class: "btn btn-secondary" %>
<% end %>
<% elsif @writable %>
<%= button_to t("tournaments.hub.publish"), public_publish_tournament_path(@tournament), method: :post, class: "btn btn-primary" %>
<% end %>
<% if @writable && !@tournament.archived? %>
<%= button_to t("tournaments.hub.archive"), public_archive_tournament_path(@tournament), method: :post, class: "btn btn-secondary", form: { data: { turbo_confirm: t("tournaments.hub.archive") } } %>
<% end %>
<%= button_to t("tournaments.hub.delete"), public_tournament_path(@tournament), method: :delete, class: "btn btn-secondary",
form: { data: { turbo_confirm: t("tournaments.hub.delete_confirm", name: @tournament.name), confirm_kind: "delete" } } %>
</div>
</header>
<% @overlap_warnings.each do |warning| %>
<div class="flash alert"><%= warning %></div>
<% end %>
<nav style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:16px">
<% %w[squadre struttura calendario dirette tabellone].each do |tab| %>
<%= link_to t("tournaments.hub.tabs.#{tab}"), public_tournament_path(@tournament, tab: tab),
class: "btn #{@tab == tab ? 'btn-primary' : 'btn-secondary'}",
style: "padding:8px 14px;font-size:0.9rem" %>
<% end %>
</nav>
<%= render "public/tournaments/tab_#{@tab}" %>
</div>
@@ -8,6 +8,7 @@
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> ·
<%= link_to t("nav.tournaments"), public_tournament_pages_path %> ·
<%= link_to t("common.faq"), public_faq_path %> ·
<%= link_to t("common.privacy"), public_privacy_path %> ·
<%= link_to t("common.cookies"), public_cookies_path %> ·
@@ -34,6 +34,7 @@
<%= link_to t("nav.faq"), public_faq_path, class: (request.path == "/faq" ? "nav-active" : nil) %>
<%= link_to t("nav.live"), public_live_index_path, class: (live_section ? "nav-active" : nil) %>
<%= link_to t("nav.teams"), public_team_pages_path, class: (request.path.start_with?("/squadre") ? "nav-active" : nil) %>
<%= link_to t("nav.tournaments"), public_tournament_pages_path, class: (request.path.start_with?("/tornei") ? "nav-active" : nil) %>
<div class="nav-actions">
<% if logged_in? %>
<% if current_user.primary_club || current_user.manageable_teams.any? %>
@@ -67,6 +67,7 @@
) %></li>
<% if plan.slug == "premium_full" %>
<li><%= raw t("pages.plans.cover_sponsor_html") %></li>
<li><%= raw t("pages.plans.tournaments_html") %></li>
<% end %>
</ul>
<% if plan.slug == "premium_full" %>
@@ -0,0 +1,32 @@
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 16px; line-height: 1.5; color: #1a1a1a; max-width: 560px;">
<% if @body_html.present? %>
<%= raw @body_html %>
<% else %>
<p style="margin: 0 0 16px;"><%= t("mailers.tournament_invite.hello") %></p>
<p style="margin: 0 0 16px;">
<%= raw t(
"mailers.tournament_invite.body_html",
inviter: @invited_by.name,
tournament: @tournament.name,
club: @club.name,
assignment: @invitation.scope_label
) %>
</p>
<% if @invitation.note.present? %>
<p style="margin: 0 0 16px;"><%= simple_format(@invitation.note) %></p>
<% end %>
<p style="margin: 0 0 16px;">
<a href="<%= @invite_url %>" style="display: inline-block; background: #e53935; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; font-weight: 600;">
<%= t("mailers.tournament_invite.cta") %>
</a>
</p>
<p style="margin: 0 0 16px; color: #444; font-size: 14px;">
<%= t("mailers.tournament_invite.link_fallback") %><br>
<a href="<%= @invite_url %>" style="color: #e53935; word-break: break-all;"><%= @invite_url %></a>
</p>
<p style="margin: 0 0 16px;"><%= t("mailers.tournament_invite.steps") %></p>
<p style="margin: 0 0 16px;"><%= t("mailers.tournament_invite.expiry", date: @expires_on) %></p>
<p style="margin: 0 0 24px; color: #666;"><%= t("mailers.tournament_invite.ignore") %></p>
<% end %>
<p style="margin: 24px 0 0; color: #888; font-size: 12px;"><%= t("mailers.tournament_invite.footer", email: MatchLiveTv.privacy_controller_email) %></p>
</div>
@@ -0,0 +1,26 @@
<% if @body_text.present? %>
<%= @body_text %>
<% else %>
<%= t("mailers.tournament_invite.hello") %>
<%= t(
"mailers.tournament_invite.body_text",
inviter: @invited_by.name,
tournament: @tournament.name,
club: @club.name,
assignment: @invitation.scope_label
) %>
<% if @invitation.note.present? %>
<%= @invitation.note %>
<% end %>
<%= t("mailers.tournament_invite.cta") %>:
<%= @invite_url %>
<%= t("mailers.tournament_invite.steps") %>
<%= t("mailers.tournament_invite.expiry", date: @expires_on) %>
<%= t("mailers.tournament_invite.ignore") %>
<% end %>
<%= t("mailers.tournament_invite.footer", email: MatchLiveTv.privacy_controller_email) %>
+1
View File
@@ -7,6 +7,7 @@ Sidekiq.configure_server do |config|
StreamPublisherSyncJob.ensure_chain
Ops::HealthMonitorJob.ensure_chain
Streams::AutoscalerJob.ensure_chain
Streams::NightCloudSweeperJob.ensure_chain
end
end
+8
View File
@@ -57,6 +57,13 @@ en:
edit_club: Edit club
subscription: Subscription
new_team: New team
tournaments: Tournaments
tournaments_full_badge: FULL
tournaments_locked_hint: Tournaments are available on Premium Full only.
new_tournament: New tournament
tournaments_heading: Tournaments
tournament_dates: Dates
tournament_status: Status
live_streams: Live streams
replay_archive: Replay archive
replay_heading: Replay
@@ -691,6 +698,7 @@ en:
invalid: Invalid invitation
wrong_email: "This invitation is for %{email}"
joined_team: You've joined the team!
joined_tournament: You're assigned to broadcast this tournament. Open the Match Live TV app to go live.
signup_to_accept: "Sign up with %{email} to accept the invitation"
registrations:
accept_terms_required: You must accept the privacy policy and terms of service to register.
+8
View File
@@ -57,6 +57,13 @@ it:
edit_club: Modifica società
subscription: Abbonamento
new_team: Nuova squadra
tournaments: Tornei
tournaments_full_badge: FULL
tournaments_locked_hint: I tornei sono disponibili solo con Premium Full.
new_tournament: Nuovo torneo
tournaments_heading: Tornei
tournament_dates: Date
tournament_status: Stato
live_streams: Dirette live
replay_archive: Archivio Replay
replay_heading: Replay
@@ -714,6 +721,7 @@ it:
invalid: Invito non valido
wrong_email: "Questo invito è per %{email}"
joined_team: Sei entrato nella squadra!
joined_tournament: Sei incaricato delle dirette del torneo. Apri lapp Match Live TV per andare in onda.
signup_to_accept: "Registrati con %{email} per accettare l'invito"
registrations:
accept_terms_required: Devi accettare l'informativa privacy e i termini di servizio per registrarti.
+11
View File
@@ -61,6 +61,17 @@ en:
expiry: "The link is valid until %{date}."
ignore: If you were not expecting this message, you can ignore it.
footer: "Match Live TV — %{email}"
tournament_invite:
subject: "Broadcast invite — %{tournament} (%{club})"
hello: Hi,
body_html: "<strong>%{inviter}</strong> invited you to broadcast matches at <strong>%{tournament}</strong> (%{club}) — %{assignment}."
body_text: "%{inviter} invited you to broadcast matches at %{tournament} (%{club}) — %{assignment}."
cta: Accept the invite
link_fallback: "If the button does not work, open this link:"
steps: "Sign in or register with this same email, accept the invite, then start streams from the Match Live TV app."
expiry: "The link is valid until %{date}."
ignore: If you were not expecting this message, you can ignore it.
footer: "Match Live TV — %{email}"
invoice:
subject: "Invoice %{number} — Match Live TV"
attachment_prefix: invoice
+11
View File
@@ -61,6 +61,17 @@ it:
expiry: "Il link è valido fino al %{date}."
ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo.
footer: "Match Live TV — %{email}"
tournament_invite:
subject: "Invito a trasmettere — %{tournament} (%{club})"
hello: Ciao,
body_html: "<strong>%{inviter}</strong> ti ha invitato a trasmettere partite del torneo <strong>%{tournament}</strong> (%{club}) — %{assignment}."
body_text: "%{inviter} ti ha invitato a trasmettere partite del torneo %{tournament} (%{club}) — %{assignment}."
cta: Accetta linvito
link_fallback: "Se il pulsante non funziona, apri questo link:"
steps: "Accedi o registrati con questa stessa email, accetta linvito e avvia le dirette dallapp Match Live TV."
expiry: "Il link è valido fino al %{date}."
ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo.
footer: "Match Live TV — %{email}"
invoice:
subject: "Fattura %{number} — Match Live TV"
attachment_prefix: fattura
+1
View File
@@ -133,6 +133,7 @@ de:
youtube_club: Vereinskanal
youtube_none: nein
cover_sponsor_html: "Anpassbares Titelbild <strong>mit Sponsoren</strong>"
tournaments_html: "Mehrtägige Turniere, Delegierung der Übertragungen und Tableau"
complete_billing: Rechnungsdaten vervollständigen
start_free: Kostenlos starten
register_with_price: "Registrieren — %{price}"
+1
View File
@@ -133,6 +133,7 @@ en:
youtube_club: club channel
youtube_none: "no"
cover_sponsor_html: "Customisable cover <strong>with sponsors</strong>"
tournaments_html: "Multi-day tournaments, delegated broadcasts and bracket"
complete_billing: Complete billing details
start_free: Start for free
register_with_price: "Register — %{price}"
+1
View File
@@ -133,6 +133,7 @@ es:
youtube_club: canal del club
youtube_none: "no"
cover_sponsor_html: "Portada personalizable <strong>con patrocinadores</strong>"
tournaments_html: "Torneos de varios días, delegación de directos y cuadro"
complete_billing: Completar datos de facturación
start_free: Empieza gratis
register_with_price: "Regístrate — %{price}"
+1
View File
@@ -133,6 +133,7 @@ fr:
youtube_club: chaîne du club
youtube_none: non
cover_sponsor_html: "Jaquette personnalisable <strong>avec sponsors</strong>"
tournaments_html: "Tournois multi-jours, délégation des directs et tableau"
complete_billing: Compléter les données de facturation
start_free: Commencer gratuitement
register_with_price: "S'inscrire — %{price}"
+1
View File
@@ -133,6 +133,7 @@ it:
youtube_club: canale società
youtube_none: "no"
cover_sponsor_html: "Copertina personalizzabile <strong>con sponsor</strong>"
tournaments_html: "Tornei multi-giorno, delega dirette e tabellone"
complete_billing: Completa dati di fatturazione
start_free: Inizia gratis
register_with_price: "Registrati — %{price}"
+245
View File
@@ -0,0 +1,245 @@
en:
tournaments:
tbd: TBD
index:
title: "Tournaments — %{name}"
heading: Tournaments
lead: Organize a multi-day tournament, delegate broadcasts, and publish groups, standings and a bracket.
new: New tournament
empty: No tournaments yet.
open: Manage
delete: Delete
col_name: Tournament
col_dates: Dates
col_status: Status
upgrade: Tournaments are available on Premium Full.
new:
title: "New tournament — %{name}"
heading: New tournament
submit: Create tournament
form:
name: Name
sport: Sport
venue: Venue
starts_on: Start
ends_on: End
format: Format
courts: Courts (one per line)
knockout_size: Knockout size
description: Description
logo: Tournament logo
logo_hint: PNG, JPEG or WebP. Shown on the public page and in the tournament list.
formats:
groups: Groups only
knockout: Knockout only
mixed: Groups + knockout
free: Open schedule
status:
draft: Draft
published: Published
live: Live
archived: Archived
hub:
title: "%{name} — Tournament"
back: "← Club"
public_page: Public page
publish: Publish
unpublish: Back to draft
archive: Archive
delete: Delete
delete_confirm: "Delete tournament “%{name}”? Teams, schedule, invites and related replays will be removed. This cannot be undone."
tabs:
squadre: Teams
struttura: Structure
calendario: Schedule
dirette: Streaming
tabellone: Bracket
overlap_warning: "Slot %{slot} has %{count} matches (plan limit: %{limit} concurrent streams)."
add_participant: Add team
participant_name: Team name
participant_group: Group
no_group: No group
link_club_team: Link a club team (optional)
add_group: Add group
generate_group_matches: Generate group matches
generate_hint: Builds the round-robin, then you can change times and courts on the schedule.
calendar_edit_hint: Edit date, time, court and teams on each row, then Save. Semis and the final can be set here or on the Bracket tab.
knockout_how_title: Semis and final
knockout_how_groups: Enter group results first. Standings decide 1st and 2nd.
knockout_how_seed: Use “Fill bracket” to put 1sts and 2nds into the semis, or pick the teams yourself.
knockout_how_advance: When you save a semi result, the final fills with the winners automatically.
knockout_round_empty: No matches in this round yet. Fill the bracket, or add a match from the schedule and pick this round.
winner_of: "Winner — %{match}"
winner_tbd: Winner to be decided
group_rank: "%{rank} %{group}"
group_rank_tbd: Qualified from group
schedule_another: Add another match
save_match: Save
logo: Logo
logo_file: Upload logo
logo_hint: Upload team logos here (PNG, JPEG or WebP). They appear on the schedule, overlay and public page.
tournament_logo_hint: Shown on the public page and in the tournament list.
squadre_edit_hint: On each row you can change name, group and logo. Upload a logo or edit the name, then press Save.
schedule_match: Schedule match
home: Home
away: Away
swap_sides: Swap home and away
streaming: Streaming
streaming_pending: pending
streaming_unassigned: Uncovered
streaming_invite: Invite
streaming_revoke: Revoke
streaming_revoke_confirm: "Revoke %{name} from this match?"
streaming_cancel: "Cancel the invite to %{email}?"
streaming_cancel_multi: "Cancel the invite to %{email}? It covers %{count} matches."
court: Court
datetime: Date and time
group: Group
round: Round
no_round:
invite_email: Operator email
stream_plan_title: Broadcast plan
stream_plan_hint: Who is streaming, and where each broadcast stands. Tick uncovered matches and invite an operator below.
stream_plan_invite_title: Invite an operator
stream_plan_invite_hint: Tick matches in the plan, or cover a whole court for a day. The email text is already below, fully editable.
stream_plan_live_col: Status
stream_plan_live:
live: Live
waiting_time: Waiting for kickoff
waiting_operator: Waiting for operator
ended: Ended
uncovered: Uncovered
stream_plan_live_count:
live:
one: "%{count} live"
other: "%{count} live"
waiting_time:
one: "%{count} waiting for kickoff"
other: "%{count} waiting for kickoff"
waiting_operator:
one: "%{count} waiting for operator"
other: "%{count} waiting for operator"
ended:
one: "%{count} ended"
other: "%{count} ended"
uncovered:
one: "%{count} uncovered"
other: "%{count} uncovered"
stream_plan_assigned:
one: "%{count} covered"
other: "%{count} covered"
stream_plan_pending:
one: "%{count} pending"
other: "%{count} pending"
stream_plan_open:
one: "%{count} uncovered"
other: "%{count} uncovered"
stream_plan_select_open: Select uncovered
stream_plan_select_match: Select this match
stream_plan_unscheduled: Unscheduled
stream_plan_match: Match
stream_plan_operator: Operator
stream_plan_invite_selected: Invite selected
stream_plan_court_day: Court and day
stream_plan_court_day_hint: If no matches are ticked, the invite covers every match on this court that day.
stream_plan_invite_court: Invite court and day
stream_plan_customize: Customize email
stream_plan_need_selection: Select at least one match, or invite a court and day.
invite_note: Note
invite_note_placeholder: E.g. Bring the tripod, arrive at 8:30
invite_match: This match
invite_court_day: Court and day
invite_submit: Send invite
invite_save: Save draft
invite_email_body: Email text
invite_email_hint: Pre-filled template, fully editable. Save a draft to come back later; when sending, {{link_invito}} becomes the real invite link. Drop an image into the editor or use the button; click the image and drag the bottom-right corner to resize.
invite_draft_hint: "Draft saved on %{time}. It stays here until you overwrite it."
invite_email_scope_generic: the matches assigned in this invite
invite_editor_toolbar: Formatting
invite_editor_bold: Bold
invite_editor_italic: Italic
invite_editor_underline: Underline
invite_editor_ul: Bulleted list
invite_editor_ol: Numbered list
invite_editor_link: Insert link
invite_editor_link_prompt: Link URL
invite_editor_image: Insert image
invite_image_invalid: Use a PNG, JPEG, WebP or GIF file.
invite_image_too_big: Image too large (max 2 MB).
invite_image_uploading: Uploading image…
invite_image_failed: Upload failed.
result: Result
save_result: Save
propose_knockout: Fill bracket
propose_hint: Fills the first knockout round with group 1sts/2nds. You can edit afterwards.
standings: Standings
played: P
won: W
lost: L
points: Pts
no_matches: No matches scheduled.
page:
title: "%{name} — %{club}"
draft_banner: Draft preview — this page is not public yet.
back_to_list: "← All tournaments"
tabs_label: Tournament sections
tabs:
risultati: Results
tabellone: Bracket
section_live: Live now
section_upcoming: Upcoming
section_board: Match board
board_hint: Every match in the tournament, with a live or replay link when available.
section_groups: Groups and standings
section_bracket: Bracket
bracket_empty: The bracket will appear once standings or knockout rounds are ready.
section_replays: Replays
watch: Watch
watch_live: Watch live
watch_replay: Watch replay
score: Score
no_media:
state:
live: Live
replay: Replay
scheduled: Scheduled
ended: Finished
waiting: Waiting
directory:
meta_title: Tournaments — Match Live TV
meta_description: Follow groups, brackets, live streams and replays of tournaments published on Match Live TV.
heading: Tournaments
hint: Boards, standings and links to live streams or replays. Open a tournament to follow it.
empty: No public tournaments right now.
follow: Follow the board →
section_on_live: Tournaments
live_hint: Groups, bracket and live streams on a page you can share.
all_link: All tournaments
flash:
tournaments:
created: Tournament created. Add teams and the schedule.
updated: Tournament updated.
published: Public page is live.
unpublished: Tournament moved back to draft.
archived: Tournament archived.
deleted: Tournament deleted.
delete_blocked_live: End live streams before deleting this tournament.
archived_locked: This tournament is archived.
group_matches_created: "Created %{count} group matches."
knockout_proposed: "Bracket updated (%{count} matches)."
participant_added: Team registered.
participant_updated: Team updated.
participant_removed: Team removed.
group_added: Group added.
group_removed: Group removed.
match_scheduled: Match added to the schedule.
match_updated: Match updated.
sides_swapped: Home and away swapped.
sides_swap_failed: Could not swap home and away.
match_deleted: Match deleted.
invite_email_sent: "Invite sent to %{email} (link valid 7 days)"
invite_email_failed: "Invite created, but the email to %{email} could not be sent. Copy and share the link."
invite_draft_saved: Email draft saved. You can send it later.
invite_canceled: Invite canceled.
assignment_revoked: Operator removed from this match.
result_saved: Result saved.
+245
View File
@@ -0,0 +1,245 @@
it:
tournaments:
tbd: Da definire
index:
title: "Tornei — %{name}"
heading: Tornei
lead: Organizza un torneo multi-giorno, delega le dirette e pubblica gironi, classifiche e tabellone.
new: Nuovo torneo
empty: Nessun torneo.
open: Gestisci
delete: Elimina
col_name: Torneo
col_dates: Date
col_status: Stato
upgrade: I tornei sono disponibili con Premium Full.
new:
title: "Nuovo torneo — %{name}"
heading: Nuovo torneo
submit: Crea torneo
form:
name: Nome
sport: Sport
venue: Sede
starts_on: Inizio
ends_on: Fine
format: Formula
courts: Campi (uno per riga)
knockout_size: Squadre in tabellone
description: Descrizione
logo: Logo del torneo
logo_hint: PNG, JPEG o WebP. Compare sulla pagina pubblica e nell'elenco tornei.
formats:
groups: Solo gironi
knockout: Solo eliminazione
mixed: Gironi + tabellone
free: Calendario libero
status:
draft: Bozza
published: Pubblicato
live: In corso
archived: Archiviato
hub:
title: "%{name} — Torneo"
back: "← Società"
public_page: Pagina pubblica
publish: Pubblica
unpublish: Torna in bozza
archive: Archivia
delete: Elimina
delete_confirm: "Eliminare il torneo «%{name}»? Squadre, calendario, inviti e replay associati verranno cancellati. Loperazione non è reversibile."
tabs:
squadre: Squadre
struttura: Struttura
calendario: Calendario
dirette: Dirette
tabellone: Tabellone
overlap_warning: "Nella fascia %{slot} hai %{count} gare (limite piano: %{limit} dirette)."
add_participant: Aggiungi squadra
participant_name: Nome squadra
participant_group: Girone
no_group: Nessun girone
link_club_team: Collega una squadra della società (facoltativo)
add_group: Aggiungi girone
generate_group_matches: Genera partite dei gironi
generate_hint: Crea il round-robin sui gironi, poi cambia orari e campi dal calendario.
calendar_edit_hint: Su ogni riga puoi cambiare data, ora, campo e squadre, poi Salva. Semifinali e finali si scelgono qui o dal tab Tabellone.
knockout_how_title: Semifinali e finali
knockout_how_groups: Chiudi i risultati dei gironi. La classifica decide chi è 1° e 2°.
knockout_how_seed: Usa «Compila tabellone» per mettere 1° e 2° in semifinale, oppure scegli tu le squadre dai menu.
knockout_how_advance: Quando salvi il risultato di una semifinale, la finale si riempie da sola con le vincenti.
knockout_round_empty: Nessuna gara in questa fase. Compila il tabellone oppure aggiungila dal calendario scegliendo la fase.
winner_of: "Vincente — %{match}"
winner_tbd: Vincente da definire
group_rank: "%{rank}° %{group}"
group_rank_tbd: Qualificata da girone
schedule_another: Aggiungi un'altra partita
save_match: Salva
logo: Logo
logo_file: Carica logo
logo_hint: I loghi delle squadre si caricano qui (PNG, JPEG o WebP). Compariranno in calendario, overlay e pagina pubblica.
tournament_logo_hint: Compare sulla pagina pubblica e nell'elenco tornei.
squadre_edit_hint: Su ogni riga puoi cambiare nome, girone e logo. Carica logo oppure modifica il nome e premi Salva.
schedule_match: Programma partita
home: Casa
away: Ospite
swap_sides: Inverti casa e ospite
streaming: Streaming
streaming_pending: in attesa
streaming_unassigned: Da coprire
streaming_invite: Invita
streaming_revoke: Revoca
streaming_revoke_confirm: "Revocare %{name} da questa partita?"
streaming_cancel: "Annullare l'invito a %{email}?"
streaming_cancel_multi: "Annullare l'invito a %{email}? Vale per %{count} partite."
court: Campo
datetime: Data e ora
group: Girone
round: Fase
no_round:
invite_email: Email operatore
stream_plan_title: Piano dirette
stream_plan_hint: Chi trasmette, e a che punto è ogni diretta. Spunta le gare da coprire e invita un operatore più sotto.
stream_plan_invite_title: Invita un operatore
stream_plan_invite_hint: Spunta le partite nel piano, oppure copri un campo intero per giornata. Il testo della mail è già qui sotto, modificabile.
stream_plan_live_col: Stato
stream_plan_live:
live: In diretta
waiting_time: "In attesa dell'orario"
waiting_operator: "In attesa dell'operatore"
ended: Terminata
uncovered: Da coprire
stream_plan_live_count:
live:
one: "%{count} in diretta"
other: "%{count} in diretta"
waiting_time:
one: "%{count} in attesa dell'orario"
other: "%{count} in attesa dell'orario"
waiting_operator:
one: "%{count} in attesa dell'operatore"
other: "%{count} in attesa dell'operatore"
ended:
one: "%{count} terminata"
other: "%{count} terminate"
uncovered:
one: "%{count} da coprire"
other: "%{count} da coprire"
stream_plan_assigned:
one: "%{count} coperta"
other: "%{count} coperte"
stream_plan_pending:
one: "%{count} in attesa"
other: "%{count} in attesa"
stream_plan_open:
one: "%{count} da coprire"
other: "%{count} da coprire"
stream_plan_select_open: Seleziona da coprire
stream_plan_select_match: Seleziona questa partita
stream_plan_unscheduled: Da programmare
stream_plan_match: Partita
stream_plan_operator: Operatore
stream_plan_invite_selected: Invita selezionate
stream_plan_court_day: Campo e giornata
stream_plan_court_day_hint: Se non spunti partite, linvito copre tutte le gare di questo campo in quella data.
stream_plan_invite_court: Invita campo e giornata
stream_plan_customize: Personalizza la mail
stream_plan_need_selection: Seleziona almeno una partita, oppure invita un campo e una giornata.
invite_note: Nota
invite_note_placeholder: Es. Porta il treppiede, arrivo alle 8:30
invite_match: Questa partita
invite_court_day: Campo e giornata
invite_submit: Invia invito
invite_save: Salva bozza
invite_email_body: Testo della mail
invite_email_hint: Modello già compilato, modificabile. Salva la bozza per riprenderla dopo; allinvio {{link_invito}} diventa il link reale. Trascina unimmagine nelleditor o usa il pulsante; per ridimensionarla cliccala e tira langolo in basso a destra.
invite_draft_hint: "Bozza salvata il %{time}. Resta qui finché non la sovrascrivi."
invite_email_scope_generic: le partite assegnate in questo invito
invite_editor_toolbar: Formattazione
invite_editor_bold: Grassetto
invite_editor_italic: Corsivo
invite_editor_underline: Sottolineato
invite_editor_ul: Elenco puntato
invite_editor_ol: Elenco numerato
invite_editor_link: Inserisci link
invite_editor_link_prompt: URL del link
invite_editor_image: Inserisci immagine
invite_image_invalid: Usa un file PNG, JPEG, WebP o GIF.
invite_image_too_big: Immagine troppo grande (max 2 MB).
invite_image_uploading: Caricamento immagine…
invite_image_failed: Caricamento non riuscito.
result: Risultato
save_result: Salva
propose_knockout: Compila tabellone
propose_hint: Compila la prima fase con 1°/2° dei gironi. Puoi spostare a mano.
standings: Classifica
played: G
won: V
lost: P
points: Pt
no_matches: Nessuna partita in calendario.
page:
title: "%{name} — %{club}"
draft_banner: Anteprima bozza — la pagina non è ancora pubblica.
back_to_list: "← Tutti i tornei"
tabs_label: Sezioni del torneo
tabs:
risultati: Risultati
tabellone: Tabellone
section_live: In onda ora
section_upcoming: Prossime gare
section_board: Cartellone
board_hint: Tutte le gare del torneo, con diretta o replay quando disponibili.
section_groups: Gironi e classifiche
section_bracket: Tabellone
bracket_empty: Il tabellone sarà visibile quando ci sono classifiche o fasi a eliminazione.
section_replays: Replay
watch: Guarda
watch_live: Guarda diretta
watch_replay: Guarda replay
score: Risultato
no_media:
state:
live: In diretta
replay: Replay
scheduled: In programma
ended: Terminata
waiting: In attesa
directory:
meta_title: Tornei in corso — Match Live TV
meta_description: Segui gironi, tabellone, dirette e replay dei tornei pubblicati su Match Live TV.
heading: Tornei
hint: Cartellone, classifiche e link alle dirette o ai replay. Scegli un torneo per seguire lo svolgimento.
empty: Nessun torneo pubblico al momento.
follow: Segui il cartellone →
section_on_live: Tornei
live_hint: Gironi, tabellone e dirette del torneo, in una pagina da condividere.
all_link: Tutti i tornei
flash:
tournaments:
created: Torneo creato. Aggiungi le squadre e il calendario.
updated: Torneo aggiornato.
published: Pagina pubblica attiva.
unpublished: Torneo riportato in bozza.
archived: Torneo archiviato.
deleted: Torneo eliminato.
delete_blocked_live: Chiudi le dirette in corso prima di eliminare il torneo.
archived_locked: Il torneo è archiviato.
group_matches_created: "Create %{count} partite dai gironi."
knockout_proposed: "Tabellone aggiornato (%{count} gare)."
participant_added: Squadra iscritta.
participant_updated: Squadra aggiornata.
participant_removed: Squadra rimossa.
group_added: Girone aggiunto.
group_removed: Girone rimosso.
match_scheduled: Partita inserita in calendario.
match_updated: Partita aggiornata.
sides_swapped: Casa e ospite invertiti.
sides_swap_failed: Impossibile invertire casa e ospite.
match_deleted: Partita eliminata.
invite_email_sent: "Invito inviato a %{email} (link valido 7 giorni)"
invite_email_failed: "Invito creato, ma lemail a %{email} non è partita. Copia e condividi il link."
invite_draft_saved: Bozza della mail salvata. Puoi inviarla quando vuoi.
invite_canceled: Invito annullato.
assignment_revoked: Operatore rimosso da questa partita.
result_saved: Risultato salvato.
+1
View File
@@ -12,6 +12,7 @@ de:
faq: FAQ
live: Live-Spiele
teams: Teams
tournaments: Turniere
my_club: Mein Verein
my_team: Mein Team
login: Anmelden

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