Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db6541c91 | ||
|
|
5d4bd8544d | ||
|
|
cc95fcacbf | ||
|
|
d7dd744c32 | ||
|
|
adfea01cdb | ||
|
|
81e7e3c9af | ||
|
|
53ad26fdbe | ||
|
|
db0e842ca7 | ||
|
|
2e67e590d6 | ||
|
|
bb342ead79 | ||
|
|
ce81bb5789 | ||
|
|
24c6ce4da0 | ||
|
|
c3ef8e91a6 | ||
|
|
b09e83db09 | ||
|
|
097e55df79 | ||
|
|
a1f4c24a43 | ||
|
|
7c7b2bf14c | ||
|
|
d52434fb2e | ||
|
|
356aa28fad |
@@ -9,14 +9,18 @@ Con la crescita del numero di clienti (e delle dirette concorrenti attese), **al
|
|||||||
|
|
||||||
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
|
Config rilevante (prod, tipicamente `infra/.env` + `StreamNode`):
|
||||||
|
|
||||||
| Parametro | Ruolo oggi (post load test 2026-08) |
|
| Parametro | Ruolo (qualità-first 2026-09) |
|
||||||
|-----------|--------------------------------------|
|
|-----------|-------------------------------|
|
||||||
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (es. 6) |
|
| `STREAM_NODE_HOME_MAX_PUBLISHERS` | Soft home (**4** — scale-out anticipato) |
|
||||||
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (es. 4; cpx12 ha tenuto 8 in probe) |
|
| `STREAM_CLOUD_MAX_PUBLISHERS` | Soft per CPX (**6**; 8 solo dopo misure QoS) |
|
||||||
| `STREAM_AUTOSCALE_MAX_OVERFLOW` / max overflow nodes | Quanti CPX in parallelo (es. 3 → tetto cluster ≈ home + N×cloud) |
|
| `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_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 |
|
| 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.
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ module Admin
|
|||||||
@filters = {
|
@filters = {
|
||||||
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
||||||
to: parse_date(params[:to]) || Time.zone.today,
|
to: parse_date(params[:to]) || Time.zone.today,
|
||||||
device: params[:device].presence
|
device: params[:device].presence,
|
||||||
|
chart_path: params[:chart_path].presence
|
||||||
}
|
}
|
||||||
@analytics_preview_active = analytics_preview_active?
|
@analytics_preview_active = analytics_preview_active?
|
||||||
|
|
||||||
@@ -36,6 +37,24 @@ module Admin
|
|||||||
max_scroll: max_scroll_by_path[path].to_i
|
max_scroll: max_scroll_by_path[path].to_i
|
||||||
}
|
}
|
||||||
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
|
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
|
||||||
|
|
||||||
|
@chart_path_options = @pages.map { |row| row[:page_path] }
|
||||||
|
if @filters[:chart_path].present? && @chart_path_options.exclude?(@filters[:chart_path])
|
||||||
|
@filters[:chart_path] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
trend_scope = scope
|
||||||
|
trend_scope = trend_scope.where(page_path: @filters[:chart_path]) if @filters[:chart_path].present?
|
||||||
|
pageviews_by_day = trend_scope.group(:day).sum(:pageview_count)
|
||||||
|
@trend = (@filters[:from]..@filters[:to]).map do |day|
|
||||||
|
{
|
||||||
|
day: day.iso8601,
|
||||||
|
pageviews: pageviews_by_day[day].to_i
|
||||||
|
}
|
||||||
|
end
|
||||||
|
@trend_totals = {
|
||||||
|
pageviews: @trend.sum { |r| r[:pageviews] }
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
|||||||
@@ -1,17 +1,34 @@
|
|||||||
module Admin
|
module Admin
|
||||||
class ClubsController < BaseController
|
class ClubsController < BaseController
|
||||||
before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
|
before_action :set_club, only: %i[show edit update grant_comped revoke_comped set_quote revoke_quote]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
|
@clubs = Club.includes(:teams, :billing_quote, { club_memberships: :user }, subscription: %i[plan admin_comped_by])
|
||||||
.order(:name)
|
.order(:name)
|
||||||
end
|
end
|
||||||
|
|
||||||
def show
|
def show
|
||||||
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
load_show_context
|
||||||
@plans = Plan.ordered.reject { |p| p.slug == "free" }
|
end
|
||||||
@teams = @club.teams.order(:name)
|
|
||||||
@quote = @club.active_billing_quote
|
def edit
|
||||||
|
load_edit_context
|
||||||
|
end
|
||||||
|
|
||||||
|
def update
|
||||||
|
Admin::UpdateClubData.call(
|
||||||
|
club: @club,
|
||||||
|
club_attrs: club_update_params,
|
||||||
|
owner_attrs: owner_params,
|
||||||
|
staff_attrs: staff_params,
|
||||||
|
invitation_attrs: invitation_params,
|
||||||
|
team_attrs: team_params
|
||||||
|
)
|
||||||
|
redirect_to admin_club_path(@club), notice: t("admin.flash.club_updated", club: @club.name)
|
||||||
|
rescue Admin::UpdateClubData::Error, ActiveRecord::RecordInvalid => e
|
||||||
|
flash.now[:alert] = e.message
|
||||||
|
load_edit_context
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
def grant_comped
|
def grant_comped
|
||||||
@@ -62,7 +79,76 @@ module Admin
|
|||||||
private
|
private
|
||||||
|
|
||||||
def set_club
|
def set_club
|
||||||
@club = Club.find(params[:id])
|
@club = Club.includes(club_memberships: :user).find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_show_context
|
||||||
|
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
|
||||||
|
@plans = Plan.ordered.reject { |p| p.slug == "free" }
|
||||||
|
@teams = @club.teams.order(:name)
|
||||||
|
@quote = @club.active_billing_quote
|
||||||
|
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
|
||||||
|
end
|
||||||
|
|
||||||
|
def load_edit_context
|
||||||
|
@teams = @club.teams.includes(:user_teams, :team_invitations).order(:name)
|
||||||
|
@owner = @club.owner
|
||||||
|
@staff_users = User.joins(:user_teams)
|
||||||
|
.where(user_teams: { team_id: @club.teams.select(:id) })
|
||||||
|
.distinct
|
||||||
|
.order(:email)
|
||||||
|
.to_a
|
||||||
|
@pending_invitations = TeamInvitation.pending
|
||||||
|
.where(team_id: @club.teams.select(:id))
|
||||||
|
.includes(:team)
|
||||||
|
.order(:email)
|
||||||
|
@sport_options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_update_params
|
||||||
|
params.require(:club).permit(
|
||||||
|
:name, :sport, :logo_url, :primary_color, :secondary_color,
|
||||||
|
:billing_entity_type, :billing_legal_name, :billing_vat_number, :billing_fiscal_code,
|
||||||
|
:billing_email, :billing_phone, :billing_address_line, :billing_city, :billing_province,
|
||||||
|
:billing_postal_code, :billing_country, :billing_recipient_code, :billing_pec
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def owner_params
|
||||||
|
params.fetch(:owner, {}).permit(:name, :email).to_h.symbolize_keys
|
||||||
|
end
|
||||||
|
|
||||||
|
def staff_params
|
||||||
|
raw = params[:staff_users]
|
||||||
|
return {} if raw.blank?
|
||||||
|
|
||||||
|
raw.permit!.to_h.each_with_object({}) do |(user_id, attrs), acc|
|
||||||
|
next unless attrs.is_a?(Hash)
|
||||||
|
|
||||||
|
acc[user_id] = attrs.slice("name", "email").symbolize_keys
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def invitation_params
|
||||||
|
raw = params[:invitations]
|
||||||
|
return {} if raw.blank?
|
||||||
|
|
||||||
|
raw.permit!.to_h.each_with_object({}) do |(invitation_id, attrs), acc|
|
||||||
|
next unless attrs.is_a?(Hash)
|
||||||
|
|
||||||
|
acc[invitation_id] = attrs.slice("email").symbolize_keys
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def team_params
|
||||||
|
raw = params[:teams]
|
||||||
|
return {} if raw.blank?
|
||||||
|
|
||||||
|
raw.permit!.to_h.each_with_object({}) do |(team_id, attrs), acc|
|
||||||
|
next unless attrs.is_a?(Hash)
|
||||||
|
|
||||||
|
acc[team_id] = attrs.slice("name", "sport").symbolize_keys
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def redirect_back_or_club(notice: nil, alert: nil)
|
def redirect_back_or_club(notice: nil, alert: nil)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ module Admin
|
|||||||
.includes(:stream_node, match: :team)
|
.includes(:stream_node, match: :team)
|
||||||
.order(started_at: :desc)
|
.order(started_at: :desc)
|
||||||
@teams = Team.includes(:matches).order(:name).limit(8)
|
@teams = Team.includes(:matches).order(:name).limit(8)
|
||||||
|
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
|
||||||
|
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def metrics
|
def metrics
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ module Admin
|
|||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
@events = @session.stream_events.recent.limit(100)
|
@events = @session.stream_events.recent.limit(100)
|
||||||
@club = @session.match.team.club
|
@club = @session.match.team.club
|
||||||
|
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
def stop
|
def stop
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class StreamConcurrencyViolationsController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
@filters = {
|
||||||
|
q: params[:q].to_s.strip.presence,
|
||||||
|
devices_differ: params[:devices_differ].to_s == "1"
|
||||||
|
}
|
||||||
|
scope = StreamConcurrencyViolation.recent
|
||||||
|
if @filters[:q]
|
||||||
|
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
|
||||||
|
scope = scope.where(
|
||||||
|
"user_email ILIKE :term OR user_name ILIKE :term OR occupying_club_name ILIKE :term OR attempted_club_name ILIKE :term OR occupying_match_label ILIKE :term OR attempted_match_label ILIKE :term",
|
||||||
|
term: term
|
||||||
|
)
|
||||||
|
end
|
||||||
|
scope = scope.two_devices if @filters[:devices_differ]
|
||||||
|
@total_count = scope.count
|
||||||
|
@violations = scope.limit(200)
|
||||||
|
@lookback_count = StreamConcurrencyViolation.lookback.count
|
||||||
|
@two_devices_count = StreamConcurrencyViolation.lookback.two_devices.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -9,34 +9,23 @@ module Api
|
|||||||
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
|
return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found
|
||||||
end
|
end
|
||||||
|
|
||||||
render json: {
|
render json: invitation_json(invitation).merge(valid: true)
|
||||||
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
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def accept
|
def accept
|
||||||
invitation = find_pending_invitation
|
invitation = find_pending_invitation
|
||||||
return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless 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: {
|
return render json: {
|
||||||
error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.",
|
error: "Questo invito è per #{email}. Accedi con quell'indirizzo email.",
|
||||||
invited_email: invitation.email
|
invited_email: email
|
||||||
}, status: :unprocessable_entity
|
}, status: :unprocessable_entity
|
||||||
end
|
end
|
||||||
|
|
||||||
invitation.accept!(current_user)
|
invitation.accept!(current_user)
|
||||||
render json: {
|
render json: invitation_json(invitation).merge(message: accept_message(invitation))
|
||||||
team_id: invitation.team_id,
|
|
||||||
team_name: invitation.team.name,
|
|
||||||
message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione."
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
@@ -45,7 +34,42 @@ module Api
|
|||||||
token = params[:token].to_s
|
token = params[:token].to_s
|
||||||
return nil if token.blank?
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -10,13 +10,22 @@ module Api
|
|||||||
|
|
||||||
def index
|
def index
|
||||||
matches = @team.matches
|
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)
|
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
|
||||||
.select(&:coach_hub_visible?)
|
unless current_user.club_admin?(@team.club)
|
||||||
render json: matches.map { |m| match_json(m) }
|
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
|
end
|
||||||
|
|
||||||
def create
|
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 = match_params.to_h
|
||||||
attrs["sport_key"] = @team.sport_key
|
attrs["sport_key"] = @team.sport_key
|
||||||
normalize_scoring_rules!(attrs)
|
normalize_scoring_rules!(attrs)
|
||||||
@@ -112,9 +121,12 @@ module Api
|
|||||||
{
|
{
|
||||||
id: match.id,
|
id: match.id,
|
||||||
team_id: match.team_id,
|
team_id: match.team_id,
|
||||||
team_name: team.name,
|
team_name: match.home_display_name,
|
||||||
opponent_name: match.opponent_name,
|
opponent_name: match.away_display_name,
|
||||||
location: match.location,
|
location: match.court_or_location,
|
||||||
|
court: match.court,
|
||||||
|
tournament_id: match.tournament_id,
|
||||||
|
tournament_name: match.tournament&.name,
|
||||||
scheduled_at: match.scheduled_at,
|
scheduled_at: match.scheduled_at,
|
||||||
sport: match.sport_key,
|
sport: match.sport_key,
|
||||||
sport_key: match.sport_key,
|
sport_key: match.sport_key,
|
||||||
@@ -126,11 +138,11 @@ module Api
|
|||||||
scoring_rules: match.scoring_rules.presence,
|
scoring_rules: match.scoring_rules.presence,
|
||||||
effective_scoring_rules: match.effective_scoring_rules,
|
effective_scoring_rules: match.effective_scoring_rules,
|
||||||
category: match.category,
|
category: match.category,
|
||||||
home_primary_color: team.effective_primary_color,
|
home_primary_color: match.home_participant&.effective_primary_color || team.effective_primary_color,
|
||||||
home_secondary_color: team.effective_secondary_color,
|
home_secondary_color: match.home_participant&.effective_secondary_color || team.effective_secondary_color,
|
||||||
home_logo_url: api_absolute_url(team.effective_logo_url),
|
home_logo_url: api_absolute_url(match.home_participant&.effective_logo_url || team.effective_logo_url),
|
||||||
opponent_primary_color: match.effective_opponent_primary_color,
|
opponent_primary_color: match.away_participant&.effective_primary_color || match.effective_opponent_primary_color,
|
||||||
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
|
opponent_logo_url: api_absolute_url(match.away_participant&.effective_logo_url || match.opponent_logo_url),
|
||||||
**match_cover_json(match),
|
**match_cover_json(match),
|
||||||
active_session_id: active&.id,
|
active_session_id: active&.id,
|
||||||
active_session_status: active&.status,
|
active_session_status: active&.status,
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ module Api
|
|||||||
def create
|
def create
|
||||||
team_ids = current_user.streamable_teams.map(&:id)
|
team_ids = current_user.streamable_teams.map(&:id)
|
||||||
match = Match.where(team_id: team_ids).find(params[:match_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
|
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
|
||||||
render json: session_json(session), status: :created
|
render json: session_json(session), status: :created
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ module Api
|
|||||||
secondary_color: team.effective_secondary_color,
|
secondary_color: team.effective_secondary_color,
|
||||||
club_id: team.club_id,
|
club_id: team.club_id,
|
||||||
club_name: team.club.name,
|
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_connected: yt.connected?,
|
||||||
youtube_selectable: yt.selectable?,
|
youtube_selectable: yt.selectable?,
|
||||||
youtube_channel_title: yt.channel_title,
|
youtube_channel_title: yt.channel_title,
|
||||||
|
|||||||
@@ -56,9 +56,9 @@ module Public
|
|||||||
def show
|
def show
|
||||||
require_club_owner!(@club)
|
require_club_owner!(@club)
|
||||||
apply_checkout_flash!
|
apply_checkout_flash!
|
||||||
@entitlements_team = @club.teams.first
|
@entitlements_team = @club.teams.visible.first
|
||||||
@entitlements = @entitlements_team&.entitlements
|
@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?
|
@replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,40 @@ module Public
|
|||||||
class InvitationsController < WebBaseController
|
class InvitationsController < WebBaseController
|
||||||
def show
|
def show
|
||||||
@token = params[:token]
|
@token = params[:token]
|
||||||
@invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s))
|
digest = Digest::SHA256.hexdigest(@token.to_s)
|
||||||
unless @invitation
|
@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")
|
redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def accept
|
def accept
|
||||||
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s))
|
token = params[:token].to_s
|
||||||
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation
|
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?
|
unless invitation || tournament_invitation
|
||||||
if current_user.email.downcase != invitation.email.downcase
|
return redirect_to public_pricing_path, alert: t("flash.invitations.invalid")
|
||||||
redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email)
|
|
||||||
return
|
|
||||||
end
|
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)
|
invitation.accept!(current_user)
|
||||||
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
|
redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team")
|
||||||
else
|
else
|
||||||
session[:pending_invite_token] = params[:token]
|
tournament_invitation.accept!(current_user)
|
||||||
redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email)
|
redirect_to public_account_path, notice: t("flash.invitations.joined_tournament")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -33,7 +33,11 @@ module Public
|
|||||||
.order(scheduled_at: :asc)
|
.order(scheduled_at: :asc)
|
||||||
.limit(50)
|
.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
|
return unless logged_in? && @club
|
||||||
|
|
||||||
@@ -83,5 +87,13 @@ module Public
|
|||||||
score: session.score_state&.as_cable_payload
|
score: session.score_state&.as_cable_payload
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def fetch_online_paths
|
||||||
|
Mediamtx::Client.new.online_path_names
|
||||||
|
rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError
|
||||||
|
[]
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -17,11 +17,18 @@ module Public
|
|||||||
session[:user_id] = @user.id
|
session[:user_id] = @user.id
|
||||||
if session[:pending_invite_token].present?
|
if session[:pending_invite_token].present?
|
||||||
token = session.delete(:pending_invite_token)
|
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
|
if invitation && invitation.email.downcase == @user.email.downcase
|
||||||
invitation.accept!(@user)
|
invitation.accept!(@user)
|
||||||
return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
|
return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team")
|
||||||
end
|
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
|
end
|
||||||
redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
|
redirect_to public_new_club_path, notice: t("flash.registrations.account_created")
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ module Public
|
|||||||
PRIVATE_WEB_CONTROLLERS = %w[
|
PRIVATE_WEB_CONTROLLERS = %w[
|
||||||
accounts clubs teams club_recordings club_billing
|
accounts clubs teams club_recordings club_billing
|
||||||
club_matches matches team_roster_members
|
club_matches matches team_roster_members
|
||||||
|
tournaments tournament_participants tournament_groups
|
||||||
|
tournament_matches tournament_invitations tournament_results
|
||||||
].freeze
|
].freeze
|
||||||
|
|
||||||
def load_site_announcements
|
def load_site_announcements
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ module Public
|
|||||||
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
|
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
|
||||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||||
{ loc: "#{base}/squadre", changefreq: "daily", 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}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||||
@@ -25,6 +26,14 @@ module Public
|
|||||||
}
|
}
|
||||||
end
|
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|
|
respond_to do |format|
|
||||||
format.xml { render layout: false }
|
format.xml { render layout: false }
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def details
|
def details
|
||||||
|
if @team.tournament_broadcast? && @team.broadcast_tournament
|
||||||
|
redirect_to public_tournament_path(@team.broadcast_tournament)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
load_team_details!
|
load_team_details!
|
||||||
render :details
|
render :details
|
||||||
end
|
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
|
||||||
@@ -188,4 +188,8 @@ module AdminHelper
|
|||||||
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
||||||
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def admin_concurrency_violation_lookback_count
|
||||||
|
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ module LegalHelper
|
|||||||
"20 agosto 2026"
|
"20 agosto 2026"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def terms_last_updated
|
||||||
|
"31 agosto 2026"
|
||||||
|
end
|
||||||
|
|
||||||
def cookie_policy_last_updated
|
def cookie_policy_last_updated
|
||||||
"3 giugno 2026"
|
"3 giugno 2026"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -53,6 +53,18 @@ module Public
|
|||||||
def live_match_card_heading(match, link_team: true)
|
def live_match_card_heading(match, link_team: true)
|
||||||
team = match.team
|
team = match.team
|
||||||
club_name = team.club&.name.presence || t("score.default_club_name")
|
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_slug = team.respond_to?(:slug) ? team.slug : nil
|
||||||
team_label = if link_team && team_slug.present?
|
team_label = if link_team && team_slug.present?
|
||||||
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
|
link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link")
|
||||||
@@ -68,6 +80,17 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def live_match_page_heading(match)
|
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
|
team = match.team
|
||||||
club_name = team.club&.name.presence || t("score.default_club_name")
|
club_name = team.club&.name.presence || t("score.default_club_name")
|
||||||
content_tag(:div, class: "live-page-heading") do
|
content_tag(:div, class: "live-page-heading") do
|
||||||
@@ -91,7 +114,7 @@ module Public
|
|||||||
else score_state.away_points
|
else score_state.away_points
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
private
|
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
|
||||||
@@ -6,6 +6,7 @@ class Club < ApplicationRecord
|
|||||||
has_many :club_memberships, dependent: :destroy
|
has_many :club_memberships, dependent: :destroy
|
||||||
has_many :users, through: :club_memberships
|
has_many :users, through: :club_memberships
|
||||||
has_many :teams, dependent: :destroy
|
has_many :teams, dependent: :destroy
|
||||||
|
has_many :tournaments, dependent: :restrict_with_error
|
||||||
has_one :youtube_credential, dependent: :destroy
|
has_one :youtube_credential, dependent: :destroy
|
||||||
has_one :subscription, dependent: :destroy
|
has_one :subscription, dependent: :destroy
|
||||||
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
|
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
|
||||||
@@ -20,7 +21,14 @@ class Club < ApplicationRecord
|
|||||||
validates :secondary_color, presence: true
|
validates :secondary_color, presence: true
|
||||||
|
|
||||||
def owner
|
def owner
|
||||||
club_memberships.find_by(role: "owner")&.user
|
memberships = club_memberships
|
||||||
|
membership =
|
||||||
|
if memberships.loaded?
|
||||||
|
memberships.detect { |m| m.role == "owner" }
|
||||||
|
else
|
||||||
|
memberships.find_by(role: "owner")
|
||||||
|
end
|
||||||
|
membership&.user
|
||||||
end
|
end
|
||||||
|
|
||||||
def owned_by?(user)
|
def owned_by?(user)
|
||||||
|
|||||||
@@ -59,18 +59,43 @@ module ClubBillingProfile
|
|||||||
def billing_profile_invoice_lines
|
def billing_profile_invoice_lines
|
||||||
lines = []
|
lines = []
|
||||||
lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present?
|
lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present?
|
||||||
lines << ["Intestatario", billing_legal_name]
|
lines << ["Intestatario", billing_legal_name] if billing_legal_name.present?
|
||||||
lines << ["P.IVA", billing_vat_number] if billing_vat_number.present?
|
lines << ["P.IVA", billing_vat_number] if billing_vat_number.present?
|
||||||
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present?
|
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present?
|
||||||
lines << ["Email fatturazione", billing_email]
|
lines << ["Email fatturazione", billing_email] if billing_email.present?
|
||||||
lines << ["Telefono", billing_phone] if billing_phone.present?
|
lines << ["Telefono", billing_phone] if billing_phone.present?
|
||||||
addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ")
|
core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank
|
||||||
lines << ["Indirizzo", addr] if addr.present?
|
if core_address.any?
|
||||||
|
core_address << billing_country if billing_country.present?
|
||||||
|
lines << ["Indirizzo", core_address.join(", ")]
|
||||||
|
end
|
||||||
lines << ["SDI", billing_recipient_code] if billing_recipient_code.present?
|
lines << ["SDI", billing_recipient_code] if billing_recipient_code.present?
|
||||||
lines << ["PEC", billing_pec] if billing_pec.present?
|
lines << ["PEC", billing_pec] if billing_pec.present?
|
||||||
lines
|
lines
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Stato profilo fiscale per badge admin: :complete | :incomplete | :absent
|
||||||
|
def billing_profile_admin_status
|
||||||
|
return :complete if billing_profile_complete?
|
||||||
|
return :absent unless billing_profile_started?
|
||||||
|
|
||||||
|
:incomplete
|
||||||
|
end
|
||||||
|
|
||||||
|
def billing_profile_started?
|
||||||
|
billing_legal_name.present? ||
|
||||||
|
billing_vat_number.present? ||
|
||||||
|
billing_fiscal_code.present? ||
|
||||||
|
billing_email.present? ||
|
||||||
|
billing_phone.present? ||
|
||||||
|
billing_address_line.present? ||
|
||||||
|
billing_city.present? ||
|
||||||
|
billing_postal_code.present? ||
|
||||||
|
billing_province.present? ||
|
||||||
|
billing_recipient_code.present? ||
|
||||||
|
billing_pec.present?
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def billing_profile_for_invoicing
|
def billing_profile_for_invoicing
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ class Match < ApplicationRecord
|
|||||||
include Coverable
|
include Coverable
|
||||||
|
|
||||||
belongs_to :team
|
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 :stream_sessions, dependent: :destroy
|
||||||
|
has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy
|
||||||
|
|
||||||
has_one_attached :opponent_logo_file
|
has_one_attached :opponent_logo_file
|
||||||
|
|
||||||
@@ -20,6 +27,8 @@ class Match < ApplicationRecord
|
|||||||
|
|
||||||
before_validation :normalize_sport_key
|
before_validation :normalize_sport_key
|
||||||
before_validation :inherit_sport_from_team, on: :create
|
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, -> {
|
scope :scheduled_for_live, -> {
|
||||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now)
|
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
|
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def result_recorded?
|
||||||
|
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
|
||||||
|
end
|
||||||
|
|
||||||
def coach_hub_visible?
|
def coach_hub_visible?
|
||||||
active = active_stream_session
|
active = active_stream_session
|
||||||
return true if active&.resumable?
|
return true if active&.resumable?
|
||||||
return true if active&.idle?
|
return true if active&.idle?
|
||||||
|
return false if result_recorded?
|
||||||
return false if stream_completed?
|
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
|
end
|
||||||
|
|
||||||
def effective_board_type
|
def effective_board_type
|
||||||
@@ -116,6 +138,30 @@ class Match < ApplicationRecord
|
|||||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||||
end
|
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).
|
# Alias legacy API/spec (colonna rinominata in sport_key).
|
||||||
def sport
|
def sport
|
||||||
sport_key
|
sport_key
|
||||||
@@ -132,12 +178,34 @@ class Match < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def inherit_sport_from_team
|
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?
|
return unless team.present?
|
||||||
|
|
||||||
# Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione.
|
# 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?
|
self.sport_key = team.sport_key if new_record? || sport_key.blank?
|
||||||
end
|
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
|
def sport_key_known
|
||||||
return if sport_key.blank?
|
return if sport_key.blank?
|
||||||
return if Sports::Catalog.find_optional(sport_key)
|
return if Sports::Catalog.find_optional(sport_key)
|
||||||
|
|||||||
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def title_or_default
|
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
|
end
|
||||||
|
|
||||||
def default_title
|
def default_title
|
||||||
match = stream_session&.match
|
match = stream_session&.match
|
||||||
return "Replay" unless match
|
return "Replay" unless match
|
||||||
|
|
||||||
"#{match.team.name} vs #{match.opponent_name}"
|
match.matchup_label
|
||||||
end
|
end
|
||||||
|
|
||||||
def recorded_at_or_fallback
|
def recorded_at_or_fallback
|
||||||
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
|
|||||||
|
|
||||||
private
|
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
|
def normalize_privacy_status
|
||||||
self.privacy_status = "public" if privacy_status == "private"
|
self.privacy_status = "public" if privacy_status == "private"
|
||||||
self.privacy_status = "unlisted" if privacy_status.blank?
|
self.privacy_status = "unlisted" if privacy_status.blank?
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class StreamConcurrencyViolation < ApplicationRecord
|
||||||
|
LOOKBACK = 30.days
|
||||||
|
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
belongs_to :occupying_session, class_name: "StreamSession", optional: true
|
||||||
|
belongs_to :attempted_session, class_name: "StreamSession", optional: true
|
||||||
|
belongs_to :occupying_club, class_name: "Club", optional: true
|
||||||
|
belongs_to :attempted_club, class_name: "Club", optional: true
|
||||||
|
|
||||||
|
validates :user_email, :occupying_match_label, :attempted_match_label, presence: true
|
||||||
|
validates :attempt_action, inclusion: { in: %w[start resume] }
|
||||||
|
|
||||||
|
scope :recent, -> { order(created_at: :desc) }
|
||||||
|
scope :since, ->(time) { where("created_at >= ?", time) }
|
||||||
|
scope :lookback, -> { since(LOOKBACK.ago) }
|
||||||
|
scope :two_devices, -> { where(devices_differ: true) }
|
||||||
|
scope :for_club, lambda { |club_id|
|
||||||
|
where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id)
|
||||||
|
}
|
||||||
|
scope :for_session, lambda { |session_id|
|
||||||
|
where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
def self.record!(attempted:, occupying:, action: "start")
|
||||||
|
user = attempted.user || occupying.user
|
||||||
|
occupying_club = occupying.match&.team&.club
|
||||||
|
attempted_club = attempted.match&.team&.club
|
||||||
|
occupying_device = occupying.client_device_label
|
||||||
|
attempted_device = attempted.client_device_label
|
||||||
|
|
||||||
|
create!(
|
||||||
|
user: user,
|
||||||
|
occupying_session: occupying,
|
||||||
|
attempted_session: attempted,
|
||||||
|
occupying_club: occupying_club,
|
||||||
|
attempted_club: attempted_club,
|
||||||
|
attempt_action: action.to_s,
|
||||||
|
user_email: user&.email.presence || "unknown",
|
||||||
|
user_name: user&.name,
|
||||||
|
occupying_club_name: occupying_club&.name,
|
||||||
|
attempted_club_name: attempted_club&.name,
|
||||||
|
occupying_match_label: occupying.match_label,
|
||||||
|
attempted_match_label: attempted.match_label,
|
||||||
|
occupying_status: occupying.status,
|
||||||
|
occupying_device: occupying_device,
|
||||||
|
attempted_device: attempted_device,
|
||||||
|
devices_differ: StreamSession.devices_differ?(occupying, attempted),
|
||||||
|
metadata: {
|
||||||
|
occupying_session_id: occupying.id,
|
||||||
|
attempted_session_id: attempted.id,
|
||||||
|
occupying_client_os: occupying.client_os,
|
||||||
|
attempted_client_os: attempted.client_os,
|
||||||
|
occupying_app_version: occupying.app_version,
|
||||||
|
attempted_app_version: attempted.app_version
|
||||||
|
}.compact
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_name
|
||||||
|
attempted_club_name.presence || occupying_club_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def operator_label
|
||||||
|
[user_name.presence, user_email].compact.join(" · ")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,8 +10,12 @@ class StreamSession < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :stream_node, optional: true
|
belongs_to :stream_node, optional: true
|
||||||
has_many :stream_events, dependent: :destroy
|
has_many :stream_events, dependent: :destroy
|
||||||
|
has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
||||||
|
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
|
||||||
|
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
||||||
|
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
|
||||||
has_one :score_state, dependent: :destroy
|
has_one :score_state, dependent: :destroy
|
||||||
has_one :recording
|
has_one :recording, dependent: :destroy
|
||||||
has_many :device_states, dependent: :destroy
|
has_many :device_states, dependent: :destroy
|
||||||
|
|
||||||
validates :platform, inclusion: { in: PLATFORMS }
|
validates :platform, inclusion: { in: PLATFORMS }
|
||||||
@@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord
|
|||||||
stream_node&.role.presence || ingest_role
|
stream_node&.role.presence || ingest_role
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def match_label
|
||||||
|
team_name = match&.team&.name
|
||||||
|
opponent = match&.opponent_name
|
||||||
|
return id.to_s if team_name.blank?
|
||||||
|
|
||||||
|
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def client_device_label
|
||||||
|
parts = []
|
||||||
|
parts << client_os if client_os.present?
|
||||||
|
device = [device_manufacturer, device_model].compact_blank.join(" ")
|
||||||
|
parts << device if device.present?
|
||||||
|
parts << "OS #{os_version}" if os_version.present?
|
||||||
|
parts.join(" · ").presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def client_device_key
|
||||||
|
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.devices_differ?(left, right)
|
||||||
|
ka = left.client_device_key
|
||||||
|
kb = right.client_device_key
|
||||||
|
return false if ka.delete("|").blank? || kb.delete("|").blank?
|
||||||
|
|
||||||
|
ka != kb
|
||||||
|
end
|
||||||
|
|
||||||
def rtmp_ingest_url
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
@@ -153,6 +186,12 @@ class StreamSession < ApplicationRecord
|
|||||||
platform == "youtube" && youtube_watch_url.present?
|
platform == "youtube" && youtube_watch_url.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def public_watchable?
|
||||||
|
return true if matchlivetv_platform? && publicly_listed?
|
||||||
|
|
||||||
|
youtube_ready?
|
||||||
|
end
|
||||||
|
|
||||||
def link_only?
|
def link_only?
|
||||||
privacy_status.in?(%w[unlisted private])
|
privacy_status.in?(%w[unlisted private])
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ class Team < ApplicationRecord
|
|||||||
include Brandable
|
include Brandable
|
||||||
include Coverable
|
include Coverable
|
||||||
|
|
||||||
|
INTERNAL_KINDS = %w[tournament_broadcast].freeze
|
||||||
|
|
||||||
belongs_to :club
|
belongs_to :club
|
||||||
has_many :user_teams, dependent: :destroy
|
has_many :user_teams, dependent: :destroy
|
||||||
has_many :users, through: :user_teams
|
has_many :users, through: :user_teams
|
||||||
@@ -9,6 +11,10 @@ class Team < ApplicationRecord
|
|||||||
has_many :recordings, dependent: :destroy
|
has_many :recordings, dependent: :destroy
|
||||||
has_many :team_invitations, dependent: :destroy
|
has_many :team_invitations, dependent: :destroy
|
||||||
has_many :roster_members, class_name: "TeamRosterMember", 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
|
has_one_attached :photo_file
|
||||||
|
|
||||||
@@ -61,6 +67,10 @@ class Team < ApplicationRecord
|
|||||||
Rails.application.routes.url_helpers.public_team_page_path(slug)
|
Rails.application.routes.url_helpers.public_team_page_path(slug)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def tournament_broadcast?
|
||||||
|
internal_kind == Tournament::INTERNAL_TEAM_KIND
|
||||||
|
end
|
||||||
|
|
||||||
def sport_label
|
def sport_label
|
||||||
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|
||||||
@@ -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
|
||||||
@@ -11,16 +11,20 @@ class User < ApplicationRecord
|
|||||||
has_many :clubs, through: :club_memberships
|
has_many :clubs, through: :club_memberships
|
||||||
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
|
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
|
||||||
has_many :stream_sessions, dependent: :nullify
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
has_many :stream_concurrency_violations, dependent: :nullify
|
||||||
|
has_many :tournament_broadcast_assignments, dependent: :destroy
|
||||||
|
|
||||||
def manageable_teams
|
def manageable_teams
|
||||||
staff_ids = teams.select(:id)
|
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))
|
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
|
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
|
||||||
def streamable_teams
|
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
|
end
|
||||||
|
|
||||||
def club_admin?(club)
|
def club_admin?(club)
|
||||||
@@ -28,6 +32,7 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def can_schedule_for?(team)
|
def can_schedule_for?(team)
|
||||||
|
return false if team.tournament_broadcast?
|
||||||
return true if team.club&.owned_by?(self)
|
return true if team.club&.owned_by?(self)
|
||||||
|
|
||||||
membership = user_teams.find_by(team: team)
|
membership = user_teams.find_by(team: team)
|
||||||
@@ -37,7 +42,7 @@ class User < ApplicationRecord
|
|||||||
end
|
end
|
||||||
|
|
||||||
def schedulable_teams_for(club)
|
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)
|
return teams if club.owned_by?(self)
|
||||||
|
|
||||||
teams.select { |team| can_schedule_for?(team) }
|
teams.select { |team| can_schedule_for?(team) }
|
||||||
@@ -54,6 +59,14 @@ class User < ApplicationRecord
|
|||||||
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
|
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
|
||||||
end
|
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)
|
def staff_role_for(team)
|
||||||
return "owner" if team.club&.owned_by?(self)
|
return "owner" if team.club&.owned_by?(self)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class UpdateClubData
|
||||||
|
class Error < StandardError; end
|
||||||
|
|
||||||
|
def self.call(**kwargs)
|
||||||
|
new(**kwargs).call
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(club:, club_attrs:, owner_attrs: {}, staff_attrs: {}, invitation_attrs: {}, team_attrs: {})
|
||||||
|
@club = club
|
||||||
|
@club_attrs = club_attrs.to_h
|
||||||
|
@owner_attrs = owner_attrs.to_h
|
||||||
|
@staff_attrs = staff_attrs.to_h
|
||||||
|
@invitation_attrs = invitation_attrs.to_h
|
||||||
|
@team_attrs = team_attrs.to_h
|
||||||
|
end
|
||||||
|
|
||||||
|
def call
|
||||||
|
ActiveRecord::Base.transaction do
|
||||||
|
update_club!
|
||||||
|
update_owner!
|
||||||
|
update_staff!
|
||||||
|
update_invitations!
|
||||||
|
update_teams!
|
||||||
|
end
|
||||||
|
@club.reload
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def update_club!
|
||||||
|
attrs = @club_attrs.dup
|
||||||
|
if attrs[:sport].present?
|
||||||
|
attrs[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
|
||||||
|
end
|
||||||
|
%i[primary_color secondary_color].each do |key|
|
||||||
|
next unless attrs.key?(key)
|
||||||
|
|
||||||
|
attrs[key] = normalize_hex(attrs[key], key == :primary_color ? "#e53935" : "#ffffff")
|
||||||
|
end
|
||||||
|
%i[
|
||||||
|
billing_legal_name billing_vat_number billing_fiscal_code billing_email billing_phone
|
||||||
|
billing_address_line billing_city billing_province billing_postal_code billing_country
|
||||||
|
billing_recipient_code billing_pec logo_url
|
||||||
|
].each do |key|
|
||||||
|
next unless attrs.key?(key)
|
||||||
|
|
||||||
|
attrs[key] = attrs[key].to_s.strip.presence
|
||||||
|
end
|
||||||
|
if attrs[:billing_province].present?
|
||||||
|
attrs[:billing_province] = attrs[:billing_province].to_s.upcase
|
||||||
|
end
|
||||||
|
if attrs[:billing_country].present?
|
||||||
|
attrs[:billing_country] = attrs[:billing_country].to_s.upcase
|
||||||
|
end
|
||||||
|
if attrs[:billing_recipient_code].present?
|
||||||
|
attrs[:billing_recipient_code] = attrs[:billing_recipient_code].to_s.upcase
|
||||||
|
end
|
||||||
|
|
||||||
|
@club.assign_attributes(attrs)
|
||||||
|
@club.save!
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
raise Error, e.record.errors.full_messages.join(", ")
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_owner!
|
||||||
|
return if @owner_attrs.blank?
|
||||||
|
|
||||||
|
owner = @club.owner
|
||||||
|
raise Error, I18n.t("admin.clubs.edit.errors.owner_missing") if owner.blank?
|
||||||
|
|
||||||
|
update_user!(owner, @owner_attrs)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_staff!
|
||||||
|
return if @staff_attrs.blank?
|
||||||
|
|
||||||
|
allowed_ids = staff_users.index_by(&:id)
|
||||||
|
@staff_attrs.each do |user_id, attrs|
|
||||||
|
user = allowed_ids[user_id.to_s] || allowed_ids[user_id]
|
||||||
|
next unless user
|
||||||
|
|
||||||
|
update_user!(user, attrs)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_invitations!
|
||||||
|
return if @invitation_attrs.blank?
|
||||||
|
|
||||||
|
pending = TeamInvitation.pending.where(team_id: @club.teams.select(:id)).index_by { |inv| inv.id.to_s }
|
||||||
|
@invitation_attrs.each do |invitation_id, attrs|
|
||||||
|
invitation = pending[invitation_id.to_s]
|
||||||
|
next unless invitation
|
||||||
|
|
||||||
|
email = attrs[:email].to_s.strip.presence
|
||||||
|
next if email.blank?
|
||||||
|
|
||||||
|
invitation.update!(email: email)
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
raise Error, e.record.errors.full_messages.join(", ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_teams!
|
||||||
|
return if @team_attrs.blank?
|
||||||
|
|
||||||
|
teams = @club.teams.index_by { |t| t.id.to_s }
|
||||||
|
@team_attrs.each do |team_id, attrs|
|
||||||
|
team = teams[team_id.to_s]
|
||||||
|
next unless team
|
||||||
|
|
||||||
|
updates = {}
|
||||||
|
updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
|
||||||
|
if attrs[:sport].present?
|
||||||
|
updates[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
|
||||||
|
end
|
||||||
|
next if updates.empty?
|
||||||
|
|
||||||
|
team.update!(updates)
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
raise Error, e.record.errors.full_messages.join(", ")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_user!(user, attrs)
|
||||||
|
updates = {}
|
||||||
|
updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
|
||||||
|
if attrs.key?(:email)
|
||||||
|
email = attrs[:email].to_s.strip.presence
|
||||||
|
updates[:email] = email if email.present?
|
||||||
|
end
|
||||||
|
return if updates.empty?
|
||||||
|
|
||||||
|
user.update!(updates)
|
||||||
|
rescue ActiveRecord::RecordInvalid => e
|
||||||
|
raise Error, e.record.errors.full_messages.join(", ")
|
||||||
|
rescue ActiveRecord::RecordNotUnique
|
||||||
|
raise Error, I18n.t("admin.clubs.edit.errors.email_taken", email: updates[:email])
|
||||||
|
end
|
||||||
|
|
||||||
|
def staff_users
|
||||||
|
User.joins(:user_teams).where(user_teams: { team_id: @club.teams.select(:id) }).distinct.to_a
|
||||||
|
end
|
||||||
|
|
||||||
|
def normalize_hex(value, fallback)
|
||||||
|
raw = value.to_s.strip
|
||||||
|
return fallback if raw.blank?
|
||||||
|
return raw.downcase if raw.match?(/\A#[0-9a-fA-F]{6}\z/)
|
||||||
|
return "##{raw.downcase}" if raw.match?(/\A[0-9a-fA-F]{6}\z/)
|
||||||
|
|
||||||
|
fallback
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -73,7 +73,7 @@ module Recordings
|
|||||||
|
|
||||||
def default_title
|
def default_title
|
||||||
match = @session.match
|
match = @session.match
|
||||||
"#{match.team.name} vs #{match.opponent_name}"
|
match.matchup_label.presence || "Replay"
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_from_session
|
def privacy_from_session
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ module Recordings
|
|||||||
FileUtils.remove_entry(dest)
|
FileUtils.remove_entry(dest)
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
unless res.is_a?(Net::HTTPSuccess) && res.body.present?
|
unless res.is_a?(Net::HTTPSuccess) && body_present?(res.body)
|
||||||
FileUtils.remove_entry(dest)
|
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
|
end
|
||||||
|
|
||||||
tar_path = File.join(dest, "recordings.tar.gz")
|
tar_path = File.join(dest, "recordings.tar.gz")
|
||||||
@@ -84,6 +84,14 @@ module Recordings
|
|||||||
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
|
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
|
||||||
end
|
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)
|
def unpack!(tar_path, dest)
|
||||||
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
|
ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL)
|
||||||
raise Error, "tar extract failed" unless ok
|
raise Error, "tar extract failed" unless ok
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
class AssertUserConcurrent
|
||||||
|
ERROR_CODE = "user_concurrent_stream"
|
||||||
|
|
||||||
|
def self.with_lock(session, action: "start")
|
||||||
|
new(session, action: action).with_lock { yield }
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(session, action: "start")
|
||||||
|
@session = session
|
||||||
|
@action = action.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_lock
|
||||||
|
occupying = nil
|
||||||
|
|
||||||
|
User.transaction do
|
||||||
|
User.lock.find(@session.user_id) if @session.user_id.present?
|
||||||
|
occupying = occupying_session
|
||||||
|
yield if occupying.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
return if occupying.nil?
|
||||||
|
|
||||||
|
StreamConcurrencyViolation.record!(
|
||||||
|
attempted: @session,
|
||||||
|
occupying: occupying,
|
||||||
|
action: @action
|
||||||
|
)
|
||||||
|
raise Teams::EntitlementError.new(
|
||||||
|
I18n.t("api.errors.user_concurrent_stream"),
|
||||||
|
code: ERROR_CODE
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def occupying_session
|
||||||
|
return if @session.user_id.blank?
|
||||||
|
|
||||||
|
StreamSession.broadcasting
|
||||||
|
.where(user_id: @session.user_id)
|
||||||
|
.where.not(id: @session.id)
|
||||||
|
.includes(:user, match: { team: :club })
|
||||||
|
.order(Arel.sql("COALESCE(started_at, updated_at) DESC"))
|
||||||
|
.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,8 +10,10 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
cancel_timeout_job
|
cancel_timeout_job
|
||||||
|
Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
|
||||||
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
||||||
@session.begin_connect! if @session.may_begin_connect?
|
@session.begin_connect! if @session.may_begin_connect?
|
||||||
|
end
|
||||||
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
||||||
log_event("resumed")
|
log_event("resumed")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
|
Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
|
||||||
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
|
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
|
||||||
@session.begin_connect! if @session.may_begin_connect?
|
@session.begin_connect! if @session.may_begin_connect?
|
||||||
@session.update!(status: "connecting") unless @session.connecting?
|
@session.update!(status: "connecting") unless @session.connecting?
|
||||||
|
end
|
||||||
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
|
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
|
||||||
broadcast_status("connecting")
|
broadcast_status("connecting")
|
||||||
@session
|
@session
|
||||||
|
|||||||
@@ -15,6 +15,11 @@ module Sessions
|
|||||||
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
|
Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing"
|
||||||
remove_mediamtx_paths!
|
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")
|
log_event("ended")
|
||||||
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
|
SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" })
|
||||||
@session
|
@session
|
||||||
|
|||||||
@@ -71,6 +71,10 @@ module Streams
|
|||||||
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
|
estimated_monthly_eur(overflow_count) <= monthly_budget_eur
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def quiet_hours?
|
||||||
|
QuietHours.active?
|
||||||
|
end
|
||||||
|
|
||||||
def reconcile!(provisioner: nil)
|
def reconcile!(provisioner: nil)
|
||||||
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
|
return Result.new(skipped: true, actions: [], metrics: metrics) unless enabled?
|
||||||
|
|
||||||
@@ -106,7 +110,8 @@ module Streams
|
|||||||
allow_cloud: allow_cloud?,
|
allow_cloud: allow_cloud?,
|
||||||
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
|
estimated_monthly_eur: estimated_monthly_eur(overflow.size),
|
||||||
monthly_budget_eur: monthly_budget_eur,
|
monthly_budget_eur: monthly_budget_eur,
|
||||||
within_budget: within_budget?(overflow.size)
|
within_budget: within_budget?(overflow.size),
|
||||||
|
quiet_hours: quiet_hours?
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -201,6 +206,7 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def warm_spare_desired?(m)
|
def warm_spare_desired?(m)
|
||||||
|
return false if self.class.quiet_hours?
|
||||||
return false if self.class.warm_spare_min <= 0
|
return false if self.class.warm_spare_min <= 0
|
||||||
|
|
||||||
need_capacity?(m) || overflow_in_use?
|
need_capacity?(m) || overflow_in_use?
|
||||||
@@ -211,6 +217,7 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def can_provision?(m)
|
def can_provision?(m)
|
||||||
|
return false if self.class.quiet_hours?
|
||||||
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
|
return false if m[:overflow_nodes] >= self.class.max_overflow_nodes
|
||||||
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
|
return false unless self.class.within_budget?(m[:overflow_nodes] + 1)
|
||||||
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
|
return false if self.class.kind == "cloud" && !self.class.allow_cloud?
|
||||||
@@ -249,6 +256,9 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def idle_long_enough?(node)
|
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
|
idle_since(node) <= self.class.idle_minutes.minutes.ago
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -258,6 +268,7 @@ module Streams
|
|||||||
end
|
end
|
||||||
|
|
||||||
def keep_as_warm_spare?(node)
|
def keep_as_warm_spare?(node)
|
||||||
|
return false if self.class.quiet_hours?
|
||||||
return false unless warm_spare_desired?(self.class.metrics)
|
return false unless warm_spare_desired?(self.class.metrics)
|
||||||
|
|
||||||
spares = StreamNode.ready.where.not(slug: NodeRegistry::HOME_SLUG).select { |n| n.active_publishers.zero? }
|
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:00–07: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
|
end
|
||||||
|
|
||||||
def base_teams
|
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 = scope.where(sport_key: @sport) if @sport
|
||||||
scope = apply_search(scope) if @q.present?
|
scope = apply_search(scope) if @q.present?
|
||||||
scope.to_a
|
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
|
||||||
@@ -50,6 +50,14 @@
|
|||||||
@filters[:device]
|
@filters[:device]
|
||||||
) %>
|
) %>
|
||||||
</label>
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.analytics.filters.chart_path") %></span>
|
||||||
|
<%= select_tag :chart_path,
|
||||||
|
options_for_select(
|
||||||
|
[[t("admin.analytics.filters.chart_path_all"), ""]] + @chart_path_options.map { |p| [p, p] },
|
||||||
|
@filters[:chart_path]
|
||||||
|
) %>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-filter-actions">
|
<div class="admin-filter-actions">
|
||||||
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
@@ -58,8 +66,66 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if @trend.any? && @trend_totals[:pageviews].positive? %>
|
||||||
|
<section class="panel admin-analytics-trend" id="admin-analytics-trend">
|
||||||
|
<div class="admin-analytics-trend__head">
|
||||||
|
<div>
|
||||||
|
<h3><%= t("admin.analytics.index.trend_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub">
|
||||||
|
<% if @filters[:chart_path].present? %>
|
||||||
|
<%= t("admin.analytics.index.trend_lead_path", path: @filters[:chart_path]) %>
|
||||||
|
<% else %>
|
||||||
|
<%= t("admin.analytics.index.trend_lead") %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
id="admin-analytics-trend-toggle"
|
||||||
|
class="admin-btn admin-btn--outline admin-btn--sm"
|
||||||
|
aria-controls="admin-analytics-trend-body"
|
||||||
|
aria-expanded="true"><%= t("admin.analytics.index.hide_chart") %></button>
|
||||||
|
</div>
|
||||||
|
<div id="admin-analytics-trend-body">
|
||||||
|
<div class="kpi-grid admin-analytics-trend__kpi">
|
||||||
|
<div class="kpi">
|
||||||
|
<div class="kpi-label">
|
||||||
|
<% if @filters[:chart_path].present? %>
|
||||||
|
<%= t("admin.analytics.index.trend_total_label_path") %>
|
||||||
|
<% else %>
|
||||||
|
<%= t("admin.analytics.index.trend_total_label") %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-value"><%= @trend_totals[:pageviews] %></div>
|
||||||
|
<div class="kpi-sub muted">
|
||||||
|
<% if @filters[:chart_path].present? %>
|
||||||
|
<code class="admin-mono"><%= @filters[:chart_path] %></code>
|
||||||
|
·
|
||||||
|
<% end %>
|
||||||
|
<%= t("admin.analytics.index.trend_total_hint", from: l(@filters[:from]), to: l(@filters[:to])) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-wrap chart-wrap--analytics">
|
||||||
|
<canvas id="chart-analytics-trend" aria-label="<%= t("admin.analytics.index.trend_title") %>"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<script>
|
||||||
|
window.adminAnalyticsTrend = <%= raw @trend.to_json %>;
|
||||||
|
window.adminAnalyticsI18n = {
|
||||||
|
pageviews: <%= raw t("admin.analytics.index.table.pageviews").to_json %>,
|
||||||
|
hideChart: <%= raw t("admin.analytics.index.hide_chart").to_json %>,
|
||||||
|
showChart: <%= raw t("admin.analytics.index.show_chart").to_json %>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="/admin-analytics.js?v=3" defer></script>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<% if @pages.any? %>
|
<% if @pages.any? %>
|
||||||
|
<h3 class="admin-analytics-pages-title"><%= t("admin.analytics.index.pages_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.analytics.index.pages_lead") %></p>
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
<table class="admin-table">
|
<table class="admin-table">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -76,14 +142,18 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<% @pages.each do |row| %>
|
<% @pages.each do |row| %>
|
||||||
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
|
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
|
||||||
<tr>
|
<% chart_params = { from: @filters[:from], to: @filters[:to], chart_path: row[:page_path] } %>
|
||||||
|
<% chart_params[:device] = @filters[:device] if @filters[:device].present? %>
|
||||||
|
<tr class="<%= "is-chart-focus" if @filters[:chart_path] == row[:page_path] %>">
|
||||||
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
|
<td><code class="admin-mono"><%= row[:page_path] %></code></td>
|
||||||
<td><%= row[:pageviews] %></td>
|
<td><%= row[:pageviews] %></td>
|
||||||
<td><%= row[:moves] %></td>
|
<td><%= row[:moves] %></td>
|
||||||
<td><%= row[:clicks] %></td>
|
<td><%= row[:clicks] %></td>
|
||||||
<td class="muted"><%= avg %>%</td>
|
<td class="muted"><%= avg %>%</td>
|
||||||
<td class="muted"><%= row[:max_scroll] %>%</td>
|
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||||
<td>
|
<td class="admin-analytics-row-actions">
|
||||||
|
<%= link_to t("admin.analytics.index.chart_for_path"), admin_analytics_path(chart_params) %>
|
||||||
|
·
|
||||||
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
|
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
|
||||||
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
|
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
|
||||||
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
|
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<% owner = club.owner %>
|
||||||
|
<% status = club.billing_profile_admin_status %>
|
||||||
|
<% lines = club.billing_profile_invoice_lines.select { |_label, value| value.present? } %>
|
||||||
|
|
||||||
|
<section class="panel admin-club-profile" style="margin:16px 0 24px">
|
||||||
|
<h3 style="font-size:1rem;margin:0 0 12px"><%= t("admin.clubs.show.profile_title") %></h3>
|
||||||
|
|
||||||
|
<h4 style="font-size:0.9rem;margin:0 0 8px;color:var(--muted)"><%= t("admin.clubs.show.owner_title") %></h4>
|
||||||
|
<% if owner %>
|
||||||
|
<dl class="billing-profile-dl" style="margin-bottom:16px">
|
||||||
|
<dt><%= t("admin.clubs.show.owner_name") %></dt>
|
||||||
|
<dd><%= owner.name %></dd>
|
||||||
|
<dt><%= t("admin.clubs.show.owner_email") %></dt>
|
||||||
|
<dd><%= mail_to owner.email %></dd>
|
||||||
|
</dl>
|
||||||
|
<% else %>
|
||||||
|
<p class="muted" style="margin:0 0 16px"><%= t("admin.clubs.show.owner_none") %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
|
<h4 style="font-size:0.9rem;margin:0;color:var(--muted)"><%= t("admin.clubs.show.billing_title") %></h4>
|
||||||
|
<span class="admin-billing-status admin-billing-status--<%= status %>">
|
||||||
|
<%= t("admin.clubs.billing_status.#{status}") %>
|
||||||
|
</span>
|
||||||
|
<%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(club), class: "admin-btn admin-btn--sm admin-btn--secondary" %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if status == :absent || lines.empty? %>
|
||||||
|
<p class="muted" style="margin:0"><%= t("admin.clubs.show.billing_none") %></p>
|
||||||
|
<% else %>
|
||||||
|
<dl class="billing-profile-dl">
|
||||||
|
<% lines.each do |label, value| %>
|
||||||
|
<dt><%= label %></dt>
|
||||||
|
<dd><%= value %></dd>
|
||||||
|
<% end %>
|
||||||
|
</dl>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<% if status == :incomplete %>
|
||||||
|
<ul class="muted" style="margin:12px 0 0;padding-left:1.2rem;font-size:0.88rem">
|
||||||
|
<% club.billing_profile_errors.each do |message| %>
|
||||||
|
<li><%= message %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
<p style="margin-bottom:16px">
|
||||||
|
<%= link_to t("admin.clubs.edit.back"), admin_club_path(@club) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2><%= t("admin.clubs.edit.title", club: @club.name) %></h2>
|
||||||
|
<p class="muted" style="margin-bottom:20px"><%= t("admin.clubs.edit.lead") %></p>
|
||||||
|
|
||||||
|
<% if flash.now[:alert].present? %>
|
||||||
|
<p class="admin-flash" style="background:#3d1b1b;border-color:#c62828"><%= flash.now[:alert] %></p>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<%= form_with url: admin_club_path(@club), method: :patch, local: true, class: "admin-form admin-form--wide" do %>
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.clubs.edit.sections.club") %></h3>
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<div>
|
||||||
|
<label for="club_name"><%= t("admin.clubs.edit.fields.name") %></label>
|
||||||
|
<input type="text" name="club[name]" id="club_name" value="<%= @club.name %>" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="club_sport"><%= t("admin.clubs.edit.fields.sport") %></label>
|
||||||
|
<select name="club[sport]" id="club_sport" required>
|
||||||
|
<% @sport_options.each do |label, key| %>
|
||||||
|
<option value="<%= key %>" <%= "selected" if @club.sport == key %>><%= label %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<div>
|
||||||
|
<label for="club_primary_color"><%= t("admin.clubs.edit.fields.primary_color") %></label>
|
||||||
|
<input type="text" name="club[primary_color]" id="club_primary_color" value="<%= @club.primary_color %>" maxlength="7">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="club_secondary_color"><%= t("admin.clubs.edit.fields.secondary_color") %></label>
|
||||||
|
<input type="text" name="club[secondary_color]" id="club_secondary_color" value="<%= @club.secondary_color %>" maxlength="7">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="club_logo_url"><%= t("admin.clubs.edit.fields.logo_url") %></label>
|
||||||
|
<input type="url" name="club[logo_url]" id="club_logo_url" value="<%= @club.logo_url %>">
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.clubs.edit.sections.billing") %></h3>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.billing_hint") %></p>
|
||||||
|
<%= render "shared/billing_profile_fields", record: @club, show_legend: false %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.clubs.edit.sections.owner") %></h3>
|
||||||
|
<% if @owner %>
|
||||||
|
<div class="admin-form-row">
|
||||||
|
<div>
|
||||||
|
<label for="owner_name"><%= t("admin.clubs.edit.fields.owner_name") %></label>
|
||||||
|
<input type="text" name="owner[name]" id="owner_name" value="<%= @owner.name %>" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="owner_email"><%= t("admin.clubs.edit.fields.owner_email") %></label>
|
||||||
|
<input type="email" name="owner[email]" id="owner_email" value="<%= @owner.email %>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="muted"><%= t("admin.clubs.show.owner_none") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.clubs.edit.sections.staff") %></h3>
|
||||||
|
<% if @staff_users.any? %>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.staff_hint") %></p>
|
||||||
|
<% @staff_users.each do |user| %>
|
||||||
|
<div class="admin-form-row" style="margin-bottom:0.75rem">
|
||||||
|
<div>
|
||||||
|
<label><%= t("admin.clubs.edit.fields.staff_name") %></label>
|
||||||
|
<input type="text" name="staff_users[<%= user.id %>][name]" value="<%= user.name %>">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label><%= t("admin.clubs.edit.fields.staff_email") %></label>
|
||||||
|
<input type="email" name="staff_users[<%= user.id %>][email]" value="<%= user.email %>">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="muted"><%= t("admin.clubs.edit.staff_none") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.clubs.edit.sections.invitations") %></h3>
|
||||||
|
<% if @pending_invitations.any? %>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.invitations_hint") %></p>
|
||||||
|
<% @pending_invitations.each do |invitation| %>
|
||||||
|
<div class="admin-form-row" style="margin-bottom:0.75rem">
|
||||||
|
<div>
|
||||||
|
<label><%= t("admin.clubs.edit.fields.invitation_team") %></label>
|
||||||
|
<input type="text" value="<%= invitation.team.name %>" disabled>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label><%= t("admin.clubs.edit.fields.invitation_email") %></label>
|
||||||
|
<input type="email" name="invitations[<%= invitation.id %>][email]" value="<%= invitation.email %>" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="muted"><%= t("admin.clubs.edit.invitations_none") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.clubs.edit.sections.teams") %></h3>
|
||||||
|
<% if @teams.any? %>
|
||||||
|
<% @teams.each do |team| %>
|
||||||
|
<div class="admin-form-row" style="margin-bottom:0.75rem">
|
||||||
|
<div>
|
||||||
|
<label><%= t("admin.clubs.edit.fields.team_name") %></label>
|
||||||
|
<input type="text" name="teams[<%= team.id %>][name]" value="<%= team.name %>" required>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label><%= t("admin.clubs.edit.fields.team_sport") %></label>
|
||||||
|
<select name="teams[<%= team.id %>][sport]">
|
||||||
|
<% @sport_options.each do |label, key| %>
|
||||||
|
<option value="<%= key %>" <%= "selected" if team.sport == key %>><%= label %></option>
|
||||||
|
<% end %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% else %>
|
||||||
|
<p class="muted"><%= t("admin.clubs.show.no_teams") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="admin-form-actions">
|
||||||
|
<%= link_to t("admin.clubs.edit.cancel"), admin_club_path(@club), class: "admin-btn admin-btn--secondary" %>
|
||||||
|
<button type="submit" class="admin-btn admin-btn--primary"><%= t("admin.clubs.edit.submit") %></button>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<th><%= t("admin.clubs.index.table.club") %></th>
|
<th><%= t("admin.clubs.index.table.club") %></th>
|
||||||
<th><%= t("admin.clubs.index.table.plan") %></th>
|
<th><%= t("admin.clubs.index.table.plan") %></th>
|
||||||
<th><%= t("admin.clubs.index.table.teams") %></th>
|
<th><%= t("admin.clubs.index.table.teams") %></th>
|
||||||
|
<th><%= t("admin.clubs.index.table.billing_profile") %></th>
|
||||||
<th><%= t("admin.clubs.index.table.comped") %></th>
|
<th><%= t("admin.clubs.index.table.comped") %></th>
|
||||||
<th><%= t("admin.clubs.index.table.stripe") %></th>
|
<th><%= t("admin.clubs.index.table.stripe") %></th>
|
||||||
<th><%= t("admin.clubs.index.table.quote") %></th>
|
<th><%= t("admin.clubs.index.table.quote") %></th>
|
||||||
@@ -18,10 +19,16 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
<% @clubs.each do |club| %>
|
<% @clubs.each do |club| %>
|
||||||
<% sub = club.subscription %>
|
<% sub = club.subscription %>
|
||||||
|
<% billing_status = club.billing_profile_admin_status %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><strong><%= club.name %></strong></td>
|
<td><strong><%= club.name %></strong></td>
|
||||||
<td><%= sub&.plan&.name || t("admin.common.free_plan") %></td>
|
<td><%= sub&.plan&.name || t("admin.common.free_plan") %></td>
|
||||||
<td><%= club.teams.size %></td>
|
<td><%= club.teams.size %></td>
|
||||||
|
<td>
|
||||||
|
<span class="admin-billing-status admin-billing-status--<%= billing_status %>">
|
||||||
|
<%= t("admin.clubs.billing_status.#{billing_status}") %>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<% if sub&.admin_comped? %>
|
<% if sub&.admin_comped? %>
|
||||||
<span style="color:#ffb74d"><%= t("admin.common.yes") %></span>
|
<span style="color:#ffb74d"><%= t("admin.common.yes") %></span>
|
||||||
|
|||||||
@@ -6,8 +6,12 @@
|
|||||||
· <%= link_to t("admin.clubs.show.recordings_archive"), admin_club_recordings_path(@club) %>
|
· <%= link_to t("admin.clubs.show.recordings_archive"), admin_club_recordings_path(@club) %>
|
||||||
· <%= link_to t("admin.clubs.show.billing_link"), admin_billing_path(club_id: @club.id) %>
|
· <%= link_to t("admin.clubs.show.billing_link"), admin_billing_path(club_id: @club.id) %>
|
||||||
· <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %>
|
· <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %>
|
||||||
|
· <%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(@club) %>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<%= render "admin/clubs/billing_profile", club: @club %>
|
||||||
|
|
||||||
|
|
||||||
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
|
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
|
||||||
<%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
|
<%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
|
||||||
|
|
||||||
@@ -46,3 +50,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<h3 style="font-size:1rem;margin-top:28px"><%= t("admin.clubs.show.concurrency_title") %></h3>
|
||||||
|
<p class="muted"><%= t("admin.clubs.show.concurrency_lead") %></p>
|
||||||
|
<%= render "admin/stream_concurrency_violations/table",
|
||||||
|
violations: @concurrency_violations,
|
||||||
|
empty_key: "admin.clubs.show.concurrency_none" %>
|
||||||
|
<% if @concurrency_violations.any? %>
|
||||||
|
<p class="kpi-sub" style="margin-top:0.75rem">
|
||||||
|
<%= link_to t("admin.clubs.show.concurrency_all"), admin_stream_concurrency_violations_path(q: @club.name) %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@@ -67,6 +67,24 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<h2><%= t("admin.dashboard.concurrency_panel.title") %></h2>
|
||||||
|
<% if @concurrency_violation_lookback.positive? %>
|
||||||
|
<p class="kpi-sub" style="margin-bottom:0.75rem">
|
||||||
|
<%= t("admin.dashboard.concurrency_panel.count", count: @concurrency_violation_lookback) %>
|
||||||
|
— <%= link_to t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path %>
|
||||||
|
</p>
|
||||||
|
<%= render "admin/stream_concurrency_violations/table",
|
||||||
|
violations: @recent_concurrency_violations,
|
||||||
|
empty_key: "admin.dashboard.concurrency_panel.none" %>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty" style="margin:0">
|
||||||
|
<%= t("admin.dashboard.concurrency_panel.none_html",
|
||||||
|
link: link_to(t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path)) %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
|
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3><%= t("admin.dashboard.disk.system_title") %></h3>
|
<h3><%= t("admin.dashboard.disk.system_title") %></h3>
|
||||||
|
|||||||
@@ -258,6 +258,16 @@
|
|||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if @concurrency_violations.any? %>
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.sessions.show.concurrency_title") %></h3>
|
||||||
|
<p class="muted"><%= t("admin.sessions.show.concurrency_lead") %></p>
|
||||||
|
<%= render "admin/stream_concurrency_violations/table",
|
||||||
|
violations: @concurrency_violations,
|
||||||
|
empty_key: "admin.sessions.show.concurrency_none" %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
||||||
<% if @events.any? %>
|
<% if @events.any? %>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<% if violations.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.when") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.account") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.club") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.occupying") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.attempted") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% violations.each do |row| %>
|
||||||
|
<tr class="<%= 'admin-row--two-devices' if row.devices_differ? %>">
|
||||||
|
<td class="muted"><%= admin_datetime(row.created_at, with_seconds: true) %></td>
|
||||||
|
<td>
|
||||||
|
<strong><%= row.user_name.presence || t("admin.common.dash") %></strong>
|
||||||
|
<div class="muted" style="font-size:0.85rem"><%= row.user_email %></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.attempted_club %>
|
||||||
|
<%= link_to row.club_name, admin_club_path(row.attempted_club) %>
|
||||||
|
<% elsif row.occupying_club %>
|
||||||
|
<%= link_to row.club_name, admin_club_path(row.occupying_club) %>
|
||||||
|
<% else %>
|
||||||
|
<%= row.club_name.presence || t("admin.common.dash") %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.occupying_session %>
|
||||||
|
<%= link_to row.occupying_match_label, admin_session_path(row.occupying_session) %>
|
||||||
|
<% else %>
|
||||||
|
<%= row.occupying_match_label %>
|
||||||
|
<% end %>
|
||||||
|
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
|
||||||
|
<span class="badge <%= admin_session_status_badge_class(row.occupying_status) %>"><%= row.occupying_status %></span>
|
||||||
|
<%= row.occupying_device.presence || t("admin.common.dash") %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.attempted_session %>
|
||||||
|
<%= link_to row.attempted_match_label, admin_session_path(row.attempted_session) %>
|
||||||
|
<% else %>
|
||||||
|
<%= row.attempted_match_label %>
|
||||||
|
<% end %>
|
||||||
|
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
|
||||||
|
<%= t("admin.stream_concurrency.action.#{row.attempt_action}") %>
|
||||||
|
· <%= row.attempted_device.presence || t("admin.common.dash") %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.devices_differ? %>
|
||||||
|
<span class="badge badge--abuse"><%= t("admin.stream_concurrency.badge.two_devices") %></span>
|
||||||
|
<% else %>
|
||||||
|
<span class="muted"><%= t("admin.stream_concurrency.badge.same_or_unknown") %></span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t(empty_key) %></p>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.stream_concurrency.index.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= t("admin.stream_concurrency.index.lead") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<div class="kpi-card <%= @lookback_count.positive? ? 'kpi-card--accent' : '' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.lookback") %></div>
|
||||||
|
<div class="kpi-value"><%= @lookback_count %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.lookback_sub") %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card <%= @two_devices_count.positive? ? 'kpi-card--danger' : '' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.two_devices") %></div>
|
||||||
|
<div class="kpi-value"><%= @two_devices_count %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.two_devices_sub") %></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="panel admin-sessions-filters">
|
||||||
|
<%= form_with url: admin_stream_concurrency_violations_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.stream_concurrency.filters.q") %></span>
|
||||||
|
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.stream_concurrency.filters.q_placeholder") %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field admin-filter-field--check">
|
||||||
|
<span><%= t("admin.stream_concurrency.filters.two_devices") %></span>
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= check_box_tag :devices_differ, "1", @filters[:devices_differ] %>
|
||||||
|
<%= t("admin.stream_concurrency.filters.two_devices_hint") %>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= submit_tag t("admin.stream_concurrency.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.stream_concurrency.filters.reset"), admin_stream_concurrency_violations_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
<span class="muted admin-filter-count">
|
||||||
|
<%= t("admin.stream_concurrency.index.results", shown: @violations.size, total: @total_count) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<%= render "admin/stream_concurrency_violations/table", violations: @violations, empty_key: "admin.stream_concurrency.index.none" %>
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<link rel="stylesheet" href="/admin.css?v=15">
|
<link rel="stylesheet" href="/admin.css?v=21">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
@@ -32,6 +32,10 @@
|
|||||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||||
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
<% abuse_count = admin_concurrency_violation_lookback_count %>
|
||||||
|
<%= link_to admin_stream_concurrency_violations_path, class: ("active" if controller_name == "stream_concurrency_violations") do %>
|
||||||
|
<%= t("admin.layout.nav.stream_concurrency") %><% if abuse_count.positive? %> <span class="admin-nav-badge"><%= abuse_count %></span><% end %>
|
||||||
|
<% end %>
|
||||||
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
|
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
|
||||||
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
|
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
|
||||||
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<%= render "shared/analytics_suppress" %>
|
<%= render "shared/analytics_suppress" %>
|
||||||
<%= yield :head %>
|
<%= 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="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=77">
|
<link rel="stylesheet" href="/marketing.css?v=102">
|
||||||
</head>
|
</head>
|
||||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
<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" %>
|
<%= render "shared/cookie_banner" %>
|
||||||
@@ -23,11 +23,13 @@
|
|||||||
</main>
|
</main>
|
||||||
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||||
<script src="/branding-form.js?v=1" defer></script>
|
<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="/roster-form.js?v=1" defer></script>
|
||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=5" defer></script>
|
<script src="/site-analytics.js?v=6" defer></script>
|
||||||
<script src="/cookie-consent.js?v=3" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
<%= render "shared/analytics_suppress" %>
|
<%= 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="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=77">
|
<link rel="stylesheet" href="/marketing.css?v=102">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<%= render "shared/marketing_footer" %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=5" defer></script>
|
<script src="/site-analytics.js?v=6" defer></script>
|
||||||
<script src="/cookie-consent.js?v=3" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<%= hidden_field_tag :plan, plan_return if plan_return %>
|
<%= hidden_field_tag :plan, plan_return if plan_return %>
|
||||||
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
|
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
|
||||||
<%= render "shared/billing_profile_fields", record: @club %>
|
<%= render "shared/billing_profile_fields", record: @club %>
|
||||||
|
<% if plan_return %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "checkout" %>
|
||||||
|
<% end %>
|
||||||
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
|
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
|
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
|
||||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||||
<%= render "shared/stripe_secure_payment" %>
|
<%= render "shared/stripe_secure_payment" %>
|
||||||
|
<% if @subscription.blank? || @subscription.plan&.slug == "free" || !@subscription.active? %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "checkout" %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
|
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
|
||||||
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
|
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
[t("club.new.plan_full"), "premium_full"]
|
[t("club.new.plan_full"), "premium_full"]
|
||||||
], params[:plan] || "free") %>
|
], params[:plan] || "free") %>
|
||||||
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
|
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
|
||||||
|
<% if params[:plan].presence_in(%w[premium_light premium_full]) %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "checkout" %>
|
||||||
|
<% end %>
|
||||||
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
|
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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.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.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" %>
|
<%= 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" %>
|
<%= link_to t("club.dashboard.live_streams"), public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||||
<% if @entitlements&.can_access_recordings? %>
|
<% if @entitlements&.can_access_recordings? %>
|
||||||
<%= link_to t("club.dashboard.replay_archive"), public_club_recordings_path(@club), class: "btn btn-secondary" %>
|
<%= 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 :title, t("auth.invitation.meta_title") %>
|
||||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||||
<% content_for :robots, "noindex, nofollow" %>
|
<% 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">
|
<section class="auth-page">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
<h1><%= raw t("auth.invitation.title_html", team_name: display_name) %></h1>
|
||||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
<p><%= raw t("auth.invitation.role_notice_html", email: email) %></p>
|
||||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
<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>
|
</p>
|
||||||
<% if logged_in? %>
|
<% if logged_in? %>
|
||||||
<%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
<%= 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",
|
"auth.invitation.login_or_signup_html",
|
||||||
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
login_link: link_to(t("auth.invitation.login_link"), public_login_path),
|
||||||
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path),
|
||||||
email: @invitation.email
|
email: email
|
||||||
) %></p>
|
) %></p>
|
||||||
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -118,13 +118,28 @@
|
|||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<% if @sessions.empty? %>
|
<% if @sessions.empty? && @upcoming_matches.any? %>
|
||||||
<% if @upcoming_matches.any? %>
|
|
||||||
<div class="empty-state empty-state--soft">
|
<div class="empty-state empty-state--soft">
|
||||||
<p><strong><%= t("live.index.empty_soft_title") %></strong></p>
|
<p><strong><%= t("live.index.empty_soft_title") %></strong></p>
|
||||||
<p><%= t("live.index.empty_soft_body") %></p>
|
<p><%= t("live.index.empty_soft_body") %></p>
|
||||||
</div>
|
</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">
|
<div class="empty-state">
|
||||||
<p><strong><%= t("live.index.empty_club_title", name: @club.name) %></strong></p>
|
<p><strong><%= t("live.index.empty_club_title", name: @club.name) %></strong></p>
|
||||||
<p>
|
<p>
|
||||||
@@ -164,8 +179,8 @@
|
|||||||
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
|
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
|
||||||
<p class="demo-label"><%= t("live.index.demo_label") %></p>
|
<p class="demo-label"><%= t("live.index.demo_label") %></p>
|
||||||
<article class="live-card live-card--demo">
|
<article class="live-card live-card--demo">
|
||||||
<h3>Tigers Volley vs ASD Eagles</h3>
|
<h3><%= MatchLiveTv::Demo.match_title %></h3>
|
||||||
<p class="meta"><%= t("live.index.demo_meta") %></p>
|
<p class="meta"><%= MatchLiveTv::Demo.live_meta %></p>
|
||||||
<p class="card-score">
|
<p class="card-score">
|
||||||
<span class="card-sets"><%= t("live.index.demo_sets") %></span>
|
<span class="card-sets"><%= t("live.index.demo_sets") %></span>
|
||||||
<span class="card-points">18 - 16</span>
|
<span class="card-points">18 - 16</span>
|
||||||
|
|||||||
@@ -70,7 +70,31 @@
|
|||||||
<%= t("pages.faq.q8_answer") %>
|
<%= t("pages.faq.q8_answer") %>
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item" id="faq-garanzia" data-mltv-event="guarantee_faq_open">
|
||||||
|
<summary><%= t("pages.faq.q9_question") %></summary>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"pages.faq.q9_answer_html",
|
||||||
|
terms_link: link_to(t("pages.faq.q9_terms_link"), public_termini_path(anchor: "garanzia-rimborso"))
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary><%= t("pages.faq.q10_question") %></summary>
|
||||||
|
<p>
|
||||||
|
<%= t("pages.faq.q10_answer") %>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (location.hash !== "#faq-garanzia") return;
|
||||||
|
var item = document.getElementById("faq-garanzia");
|
||||||
|
if (item) item.open = true;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<p style="text-align:center;margin:40px 0">
|
<p style="text-align:center;margin:40px 0">
|
||||||
<%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
<%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
<div class="features-yt-mock__meta">
|
<div class="features-yt-mock__meta">
|
||||||
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
|
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
|
||||||
<div class="features-yt-mock__text">
|
<div class="features-yt-mock__text">
|
||||||
<strong><%= t("pages.features.youtube_mock_channel") %></strong>
|
<strong><%= MatchLiveTv::Demo.home_team %></strong>
|
||||||
<span><%= t("pages.features.youtube_mock_subs") %></span>
|
<span><%= t("pages.features.youtube_mock_subs") %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,6 +196,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%= render "shared/sponsor_cover_promo" %>
|
||||||
|
|
||||||
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
||||||
<div class="features-split features-split--reverse">
|
<div class="features-split features-split--reverse">
|
||||||
<div class="features-split__copy">
|
<div class="features-split__copy">
|
||||||
@@ -218,7 +220,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
|
||||||
<span class="features-archive-mock__info">
|
<span class="features-archive-mock__info">
|
||||||
<strong><%= t("pages.features.replay_mock_1_title") %></strong>
|
<strong><%= MatchLiveTv::Demo.match_title %></strong>
|
||||||
<em><%= t("pages.features.replay_mock_1_meta") %></em>
|
<em><%= t("pages.features.replay_mock_1_meta") %></em>
|
||||||
</span>
|
</span>
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
@@ -226,7 +228,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
|
||||||
<span class="features-archive-mock__info">
|
<span class="features-archive-mock__info">
|
||||||
<strong><%= t("pages.features.replay_mock_2_title") %></strong>
|
<strong><%= MatchLiveTv::Demo.match_title %></strong>
|
||||||
<em><%= t("pages.features.replay_mock_2_meta") %></em>
|
<em><%= t("pages.features.replay_mock_2_meta") %></em>
|
||||||
</span>
|
</span>
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
@@ -234,7 +236,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
|
||||||
<span class="features-archive-mock__info">
|
<span class="features-archive-mock__info">
|
||||||
<strong><%= t("pages.features.replay_mock_3_title") %></strong>
|
<strong><%= MatchLiveTv::Demo.match_title %></strong>
|
||||||
<em><%= t("pages.features.replay_mock_3_meta") %></em>
|
<em><%= t("pages.features.replay_mock_3_meta") %></em>
|
||||||
</span>
|
</span>
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%= render "shared/sponsor_cover_promo", variant: :compact %>
|
||||||
|
|
||||||
<section class="section wrap plans-teaser">
|
<section class="section wrap plans-teaser">
|
||||||
<h2><%= t("home.plans_title") %></h2>
|
<h2><%= t("home.plans_title") %></h2>
|
||||||
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
|
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
|
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
|
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "cta" %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section wrap seo-prose">
|
<section class="section wrap seo-prose">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
|
<%= render "shared/sponsor_cover_promo", variant: :compact, show_cta: false, nested: true %>
|
||||||
|
|
||||||
<div class="table-scroll compare-table-wrap">
|
<div class="table-scroll compare-table-wrap">
|
||||||
<table class="compare-table">
|
<table class="compare-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -50,6 +52,12 @@
|
|||||||
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
||||||
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||||
|
<tr>
|
||||||
|
<td><%= t("pages.pricing.table_cover_sponsor") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_no") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_no") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_yes") %></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= t("pages.pricing.table_price") %></td>
|
<td><%= t("pages.pricing.table_price") %></td>
|
||||||
<td><%= t("pages.pricing.table_price_free") %></td>
|
<td><%= t("pages.pricing.table_price_free") %></td>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<div class="wrap legal-doc">
|
<div class="wrap legal-doc">
|
||||||
<h1><%= t("legal.terms.h1") %></h1>
|
<h1><%= t("legal.terms.h1") %></h1>
|
||||||
<p class="legal-meta"><%= t("legal.terms.meta", date: legal_last_updated) %></p>
|
<p class="legal-meta"><%= t("legal.terms.meta", date: terms_last_updated) %></p>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.terms.s1_title") %></h2>
|
<h2><%= t("legal.terms.s1_title") %></h2>
|
||||||
@@ -43,6 +43,21 @@
|
|||||||
<p><%= t("legal.terms.s3_p3") %></p>
|
<p><%= t("legal.terms.s3_p3") %></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="garanzia-rimborso">
|
||||||
|
<h2><%= t("legal.terms.s3b_title") %></h2>
|
||||||
|
<p><%= t("legal.terms.s3b_p1") %></p>
|
||||||
|
<p><%= t("legal.terms.s3b_p2") %></p>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"legal.terms.s3b_p3_html",
|
||||||
|
email_link: link_to(MatchLiveTv.support_email, "mailto:#{MatchLiveTv.support_email}")
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
<p><%= t("legal.terms.s3b_p4") %></p>
|
||||||
|
<p><%= t("legal.terms.s3b_p5") %></p>
|
||||||
|
<p><%= t("legal.terms.s3b_p6") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.terms.s4_title") %></h2>
|
<h2><%= t("legal.terms.s4_title") %></h2>
|
||||||
<p><strong><%= t("legal.terms.s4_lead1") %></strong></p>
|
<p><strong><%= t("legal.terms.s4_lead1") %></strong></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 %>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user