diff --git a/backend/app/controllers/api/v1/invitations_controller.rb b/backend/app/controllers/api/v1/invitations_controller.rb index a58d967..5db75bd 100644 --- a/backend/app/controllers/api/v1/invitations_controller.rb +++ b/backend/app/controllers/api/v1/invitations_controller.rb @@ -9,34 +9,23 @@ module Api return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found end - render json: { - valid: true, - email: invitation.email, - team_id: invitation.team_id, - team_name: invitation.team.name, - club_name: invitation.team.club.name, - staff_kind: invitation.staff_kind, - expires_at: invitation.expires_at - } + render json: invitation_json(invitation).merge(valid: true) end def accept invitation = find_pending_invitation return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation - if current_user.email.downcase != invitation.email.downcase + email = invitation.email + if current_user.email.downcase != email.downcase return render json: { - error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.", - invited_email: invitation.email + error: "Questo invito è per #{email}. Accedi con quell'indirizzo email.", + invited_email: email }, status: :unprocessable_entity end invitation.accept!(current_user) - render json: { - team_id: invitation.team_id, - team_name: invitation.team.name, - message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione." - } + render json: invitation_json(invitation).merge(message: accept_message(invitation)) end private @@ -45,7 +34,42 @@ module Api token = params[:token].to_s return nil if token.blank? - TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) + digest = Digest::SHA256.hexdigest(token) + TeamInvitation.pending.find_by(token_digest: digest) || + TournamentBroadcastInvitation.pending.find_by(token_digest: digest) + end + + def invitation_json(invitation) + if invitation.is_a?(TournamentBroadcastInvitation) + tournament = invitation.tournament + { + email: invitation.email, + tournament_id: tournament.id, + tournament_name: tournament.name, + club_name: tournament.club.name, + team_id: tournament.broadcast_team_id, + team_name: tournament.name, + staff_kind: "transmission", + expires_at: invitation.expires_at + } + else + { + email: invitation.email, + team_id: invitation.team_id, + team_name: invitation.team.name, + club_name: invitation.team.club.name, + staff_kind: invitation.staff_kind, + expires_at: invitation.expires_at + } + end + end + + def accept_message(invitation) + if invitation.is_a?(TournamentBroadcastInvitation) + "Sei incaricato delle dirette per #{invitation.tournament.name}." + else + "Sei entrato in #{invitation.team.name} come responsabile trasmissione." + end end end end diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index bd70f27..90fcf2c 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -10,13 +10,22 @@ module Api def index matches = @team.matches - .includes(:team, :stream_sessions) + .includes(:team, :stream_sessions, :home_participant, :away_participant) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) - .select(&:coach_hub_visible?) - render json: matches.map { |m| match_json(m) } + unless current_user.club_admin?(@team.club) + if @team.tournament_broadcast? + assigned_ids = current_user.tournament_broadcast_assignments.select(:match_id) + matches = matches.where(id: assigned_ids) + end + end + render json: matches.select(&:coach_hub_visible?).map { |m| match_json(m) } end def create + if @team.tournament_broadcast? + return render json: { error: "Le partite del torneo si programmano dal sito web." }, status: :unprocessable_entity + end + attrs = match_params.to_h attrs["sport_key"] = @team.sport_key normalize_scoring_rules!(attrs) @@ -112,9 +121,12 @@ module Api { id: match.id, team_id: match.team_id, - team_name: team.name, - opponent_name: match.opponent_name, - location: match.location, + team_name: match.home_display_name, + opponent_name: match.away_display_name, + location: match.court_or_location, + court: match.court, + tournament_id: match.tournament_id, + tournament_name: match.tournament&.name, scheduled_at: match.scheduled_at, sport: match.sport_key, sport_key: match.sport_key, @@ -126,11 +138,11 @@ module Api scoring_rules: match.scoring_rules.presence, effective_scoring_rules: match.effective_scoring_rules, category: match.category, - home_primary_color: team.effective_primary_color, - home_secondary_color: team.effective_secondary_color, - home_logo_url: api_absolute_url(team.effective_logo_url), - opponent_primary_color: match.effective_opponent_primary_color, - opponent_logo_url: api_absolute_url(match.opponent_logo_url), + home_primary_color: match.home_participant&.effective_primary_color || team.effective_primary_color, + home_secondary_color: match.home_participant&.effective_secondary_color || team.effective_secondary_color, + home_logo_url: api_absolute_url(match.home_participant&.effective_logo_url || team.effective_logo_url), + opponent_primary_color: match.away_participant&.effective_primary_color || match.effective_opponent_primary_color, + opponent_logo_url: api_absolute_url(match.away_participant&.effective_logo_url || match.opponent_logo_url), **match_cover_json(match), active_session_id: active&.id, active_session_status: active&.status, diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index 4440407..453188d 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -6,6 +6,10 @@ module Api def create team_ids = current_user.streamable_teams.map(&:id) match = Match.where(team_id: team_ids).find(params[:match_id]) + unless current_user.can_broadcast_match?(match) + return render json: { error: "Non sei incaricato di trasmettere questa partita" }, status: :forbidden + end + session = Sessions::Create.new(user: current_user, match: match, params: session_params).call render json: session_json(session), status: :created end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 9b890c7..81184ce 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -110,6 +110,9 @@ module Api secondary_color: team.effective_secondary_color, club_id: team.club_id, club_name: team.club.name, + tournament_broadcast: team.tournament_broadcast?, + tournament_id: team.broadcast_tournament&.id, + tournament_name: team.broadcast_tournament&.name, youtube_connected: yt.connected?, youtube_selectable: yt.selectable?, youtube_channel_title: yt.channel_title, diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index 2249a55..828eef7 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -56,9 +56,9 @@ module Public def show require_club_owner!(@club) apply_checkout_flash! - @entitlements_team = @club.teams.first + @entitlements_team = @club.teams.visible.first @entitlements = @entitlements_team&.entitlements - @teams = @club.teams.order(:name) + @teams = @club.teams.visible.order(:name) @replay_stats = Recordings::ClubStats.new(@club).call if @entitlements&.can_access_recordings? end diff --git a/backend/app/controllers/public/invitations_controller.rb b/backend/app/controllers/public/invitations_controller.rb index fe822ee..d581c23 100644 --- a/backend/app/controllers/public/invitations_controller.rb +++ b/backend/app/controllers/public/invitations_controller.rb @@ -2,26 +2,40 @@ module Public class InvitationsController < WebBaseController def show @token = params[:token] - @invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(@token.to_s)) - unless @invitation + digest = Digest::SHA256.hexdigest(@token.to_s) + @invitation = TeamInvitation.pending.find_by(token_digest: digest) + @tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless @invitation + unless @invitation || @tournament_invitation redirect_to public_pricing_path, alert: t("flash.invitations.invalid_or_expired") end end def accept - invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(params[:token].to_s)) - return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") unless invitation + token = params[:token].to_s + digest = Digest::SHA256.hexdigest(token) + invitation = TeamInvitation.pending.find_by(token_digest: digest) + tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) unless invitation - if logged_in? - if current_user.email.downcase != invitation.email.downcase - redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: invitation.email) - return - end + unless invitation || tournament_invitation + return redirect_to public_pricing_path, alert: t("flash.invitations.invalid") + end + + target_email = (invitation || tournament_invitation).email + unless logged_in? + session[:pending_invite_token] = token + return redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: target_email) + end + + if current_user.email.downcase != target_email.downcase + return redirect_to public_pricing_path, alert: t("flash.invitations.wrong_email", email: target_email) + end + + if invitation invitation.accept!(current_user) redirect_to public_team_details_path(invitation.team), notice: t("flash.invitations.joined_team") else - session[:pending_invite_token] = params[:token] - redirect_to public_signup_path, notice: t("flash.invitations.signup_to_accept", email: invitation.email) + tournament_invitation.accept!(current_user) + redirect_to public_account_path, notice: t("flash.invitations.joined_tournament") end end end diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb index a56575b..5e1eed9 100644 --- a/backend/app/controllers/public/live_controller.rb +++ b/backend/app/controllers/public/live_controller.rb @@ -33,7 +33,11 @@ module Public .order(scheduled_at: :asc) .limit(50) - @online_paths = Mediamtx::Client.new.online_path_names + tournaments = Tournament.listed_on_live.with_attached_logo_file.includes(:club).search_public(@query) + tournaments = tournaments.where(club_id: @club.id) if @club + @public_tournaments = tournaments.order(starts_on: :desc, name: :asc).limit(12) + + @online_paths = fetch_online_paths return unless logged_in? && @club @@ -83,5 +87,13 @@ module Public score: session.score_state&.as_cable_payload } end + + private + + def fetch_online_paths + Mediamtx::Client.new.online_path_names + rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError + [] + end end end diff --git a/backend/app/controllers/public/registrations_controller.rb b/backend/app/controllers/public/registrations_controller.rb index 8d22eb9..f2d924d 100644 --- a/backend/app/controllers/public/registrations_controller.rb +++ b/backend/app/controllers/public/registrations_controller.rb @@ -17,11 +17,18 @@ module Public session[:user_id] = @user.id if session[:pending_invite_token].present? token = session.delete(:pending_invite_token) - invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) + digest = Digest::SHA256.hexdigest(token) + invitation = TeamInvitation.pending.find_by(token_digest: digest) if invitation && invitation.email.downcase == @user.email.downcase invitation.accept!(@user) return redirect_to public_team_details_path(invitation.team), notice: t("flash.registrations.welcome_to_team") end + + tournament_invitation = TournamentBroadcastInvitation.pending.find_by(token_digest: digest) + if tournament_invitation && tournament_invitation.email.downcase == @user.email.downcase + tournament_invitation.accept!(@user) + return redirect_to public_account_path, notice: t("flash.invitations.joined_tournament") + end end redirect_to public_new_club_path, notice: t("flash.registrations.account_created") else diff --git a/backend/app/controllers/public/site_base_controller.rb b/backend/app/controllers/public/site_base_controller.rb index b5bea4d..499c709 100644 --- a/backend/app/controllers/public/site_base_controller.rb +++ b/backend/app/controllers/public/site_base_controller.rb @@ -45,6 +45,8 @@ module Public PRIVATE_WEB_CONTROLLERS = %w[ accounts clubs teams club_recordings club_billing club_matches matches team_roster_members + tournaments tournament_participants tournament_groups + tournament_matches tournament_invitations tournament_results ].freeze def load_site_announcements diff --git a/backend/app/controllers/public/sitemap_controller.rb b/backend/app/controllers/public/sitemap_controller.rb index 9f82fae..dbdbe60 100644 --- a/backend/app/controllers/public/sitemap_controller.rb +++ b/backend/app/controllers/public/sitemap_controller.rb @@ -11,6 +11,7 @@ module Public { loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" }, { loc: "#{base}/live", changefreq: "hourly", priority: "0.85" }, { loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" }, + { loc: "#{base}/tornei", changefreq: "daily", priority: "0.8" }, { loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/support", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" }, @@ -25,6 +26,14 @@ module Public } end + Tournament.visible_to_public.find_each do |tournament| + @entries << { + loc: "#{base}/tornei/#{tournament.slug}", + changefreq: "daily", + priority: "0.75" + } + end + respond_to do |format| format.xml { render layout: false } end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 2abde0d..ef6db12 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -27,6 +27,11 @@ module Public end def details + if @team.tournament_broadcast? && @team.broadcast_tournament + redirect_to public_tournament_path(@team.broadcast_tournament) + return + end + load_team_details! render :details end diff --git a/backend/app/controllers/public/tournament_assignments_controller.rb b/backend/app/controllers/public/tournament_assignments_controller.rb new file mode 100644 index 0000000..52e6433 --- /dev/null +++ b/backend/app/controllers/public/tournament_assignments_controller.rb @@ -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 diff --git a/backend/app/controllers/public/tournament_groups_controller.rb b/backend/app/controllers/public/tournament_groups_controller.rb new file mode 100644 index 0000000..8fec839 --- /dev/null +++ b/backend/app/controllers/public/tournament_groups_controller.rb @@ -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 diff --git a/backend/app/controllers/public/tournament_invitations_controller.rb b/backend/app/controllers/public/tournament_invitations_controller.rb new file mode 100644 index 0000000..828d3e5 --- /dev/null +++ b/backend/app/controllers/public/tournament_invitations_controller.rb @@ -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 diff --git a/backend/app/controllers/public/tournament_matches_controller.rb b/backend/app/controllers/public/tournament_matches_controller.rb new file mode 100644 index 0000000..8f827ae --- /dev/null +++ b/backend/app/controllers/public/tournament_matches_controller.rb @@ -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.played? || match.result_status.to_s.start_with?("walkover") + 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 diff --git a/backend/app/controllers/public/tournament_pages_controller.rb b/backend/app/controllers/public/tournament_pages_controller.rb new file mode 100644 index 0000000..18a23e6 --- /dev/null +++ b/backend/app/controllers/public/tournament_pages_controller.rb @@ -0,0 +1,79 @@ +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, + home_participant: { logo_file_attachment: :blob }, + away_participant: { logo_file_attachment: :blob } + ) + .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc) + + @live_sessions = StreamSession + .broadcasting + .publicly_listed + .where(platform: "matchlivetv") + .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) + @live_by_match_id = @live_sessions.index_by(&:match_id) + + @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 diff --git a/backend/app/controllers/public/tournament_participants_controller.rb b/backend/app/controllers/public/tournament_participants_controller.rb new file mode 100644 index 0000000..bd1b28e --- /dev/null +++ b/backend/app/controllers/public/tournament_participants_controller.rb @@ -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 diff --git a/backend/app/controllers/public/tournament_results_controller.rb b/backend/app/controllers/public/tournament_results_controller.rb new file mode 100644 index 0000000..f88fe37 --- /dev/null +++ b/backend/app/controllers/public/tournament_results_controller.rb @@ -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 diff --git a/backend/app/controllers/public/tournaments_controller.rb b/backend/app/controllers/public/tournaments_controller.rb new file mode 100644 index 0000000..15c90bf --- /dev/null +++ b/backend/app/controllers/public/tournaments_controller.rb @@ -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 diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index 6980724..8963e6b 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -53,6 +53,18 @@ module Public def live_match_card_heading(match, link_team: true) team = match.team club_name = team.club&.name.presence || t("score.default_club_name") + if match.tournament_match? + tournament = match.tournament + heading_name = tournament&.name.presence || club_name + matchup = match.matchup_label + return content_tag(:h3, class: "live-card__title") do + safe_join([ + content_tag(:span, heading_name, class: "live-card__club"), + content_tag(:span, matchup, class: "live-card__matchup") + ]) + end + end + team_slug = team.respond_to?(:slug) ? team.slug : nil team_label = if link_team && team_slug.present? link_to(team.name, public_team_page_path(team_slug), class: "live-card__team-link") @@ -68,6 +80,17 @@ module Public end def live_match_page_heading(match) + if match.tournament_match? + tournament = match.tournament + club_name = tournament&.name.presence || match.team.club&.name.presence || t("score.default_club_name") + return content_tag(:div, class: "live-page-heading") do + safe_join([ + content_tag(:p, club_name, class: "live-page-heading__club"), + content_tag(:h1, match.matchup_label, class: "live-page-heading__matchup") + ]) + end + end + team = match.team club_name = team.club&.name.presence || t("score.default_club_name") content_tag(:div, class: "live-page-heading") do @@ -91,7 +114,7 @@ module Public else score_state.away_points end - t("score.points_label", team: match.team.name, home: home, away: away, opponent: match.opponent_name) + t("score.points_label", team: match.home_display_name, home: home, away: away, opponent: match.away_display_name) end private diff --git a/backend/app/helpers/public/tournaments_helper.rb b/backend/app/helpers/public/tournaments_helper.rb new file mode 100644 index 0000000..176d224 --- /dev/null +++ b/backend/app/helpers/public/tournaments_helper.rb @@ -0,0 +1,163 @@ +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]) + + 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 :scheduled if match.scheduled_upcoming? + return :ended if match.played? || match.home_score.present? + + :waiting + 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.played? || match.result_status.to_s.start_with?("walkover") + 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 diff --git a/backend/app/mailers/tournaments/invitation_mailer.rb b/backend/app/mailers/tournaments/invitation_mailer.rb new file mode 100644 index 0000000..b223f30 --- /dev/null +++ b/backend/app/mailers/tournaments/invitation_mailer.rb @@ -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 diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb index 4926162..bcb4f0f 100644 --- a/backend/app/models/club.rb +++ b/backend/app/models/club.rb @@ -6,6 +6,7 @@ class Club < ApplicationRecord has_many :club_memberships, dependent: :destroy has_many :users, through: :club_memberships has_many :teams, dependent: :destroy + has_many :tournaments, dependent: :restrict_with_error has_one :youtube_credential, dependent: :destroy has_one :subscription, dependent: :destroy has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index 9580ec7..7690117 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -2,7 +2,14 @@ class Match < ApplicationRecord include Coverable belongs_to :team + belongs_to :tournament, optional: true + belongs_to :home_participant, class_name: "TournamentParticipant", optional: true + belongs_to :away_participant, class_name: "TournamentParticipant", optional: true + belongs_to :tournament_group, optional: true + belongs_to :tournament_round, optional: true + belongs_to :winner_participant, class_name: "TournamentParticipant", optional: true has_many :stream_sessions, dependent: :destroy + has_many :broadcast_assignments, class_name: "TournamentBroadcastAssignment", dependent: :destroy has_one_attached :opponent_logo_file @@ -20,6 +27,8 @@ class Match < ApplicationRecord before_validation :normalize_sport_key before_validation :inherit_sport_from_team, on: :create + before_validation :sync_tournament_display_names + after_commit :sync_tournament_broadcast_assignments, on: %i[create update] scope :scheduled_for_live, -> { where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now) @@ -116,6 +125,30 @@ class Match < ApplicationRecord Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize end + def tournament_match? + has_attribute?(:tournament_id) && tournament_id.present? + end + + def home_display_name + home_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : team.name) + end + + def away_display_name + away_participant&.name.presence || (tournament_match? ? I18n.t("tournaments.tbd") : opponent_name.presence) + end + + def matchup_label + "#{home_display_name} vs #{away_display_name}" + end + + def played? + result_status == "played" + end + + def court_or_location + court.presence || location + end + # Alias legacy API/spec (colonna rinominata in sport_key). def sport sport_key @@ -132,12 +165,34 @@ class Match < ApplicationRecord end def inherit_sport_from_team + if has_attribute?(:tournament_id) && tournament.present? + self.sport_key = tournament.sport_key if new_record? || sport_key.blank? + return + end + return unless team.present? # Il default DB (pallavolo) non deve prevalere sullo sport della squadra alla creazione. self.sport_key = team.sport_key if new_record? || sport_key.blank? end + def sync_tournament_display_names + return unless has_attribute?(:tournament_id) && tournament_id.present? + + self.opponent_name = away_display_name if away_participant.present? || opponent_name.blank? + venue_court = [tournament&.venue, court].compact_blank + self.location = venue_court.join(" — ") if venue_court.any? + + self.category = tournament.name if category.blank? && tournament.present? + end + + def sync_tournament_broadcast_assignments + return unless has_attribute?(:tournament_id) && tournament_id.present? + return unless saved_change_to_court? || saved_change_to_scheduled_at? || previously_new_record? + + Tournaments::SyncAssignments.sync_match!(self) + end + def sport_key_known return if sport_key.blank? return if Sports::Catalog.find_optional(sport_key) diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 3cb2d62..4325b96 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -2,6 +2,8 @@ class Team < ApplicationRecord include Brandable include Coverable + INTERNAL_KINDS = %w[tournament_broadcast].freeze + belongs_to :club has_many :user_teams, dependent: :destroy has_many :users, through: :user_teams @@ -9,6 +11,10 @@ class Team < ApplicationRecord has_many :recordings, dependent: :destroy has_many :team_invitations, dependent: :destroy has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy + has_one :broadcast_tournament, class_name: "Tournament", foreign_key: :broadcast_team_id, inverse_of: :broadcast_team + + scope :visible, -> { where("internal_kind IS NULL OR internal_kind = ''") } + scope :tournament_broadcast, -> { where(internal_kind: Tournament::INTERNAL_TEAM_KIND) } has_one_attached :photo_file @@ -61,6 +67,10 @@ class Team < ApplicationRecord Rails.application.routes.url_helpers.public_team_page_path(slug) end + def tournament_broadcast? + internal_kind == Tournament::INTERNAL_TEAM_KIND + end + def sport_label Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize end diff --git a/backend/app/models/tournament.rb b/backend/app/models/tournament.rb new file mode 100644 index 0000000..37495a6 --- /dev/null +++ b/backend/app/models/tournament.rb @@ -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 diff --git a/backend/app/models/tournament_broadcast_assignment.rb b/backend/app/models/tournament_broadcast_assignment.rb new file mode 100644 index 0000000..b93ffc6 --- /dev/null +++ b/backend/app/models/tournament_broadcast_assignment.rb @@ -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 diff --git a/backend/app/models/tournament_broadcast_invitation.rb b/backend/app/models/tournament_broadcast_invitation.rb new file mode 100644 index 0000000..39541ab --- /dev/null +++ b/backend/app/models/tournament_broadcast_invitation.rb @@ -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 diff --git a/backend/app/models/tournament_group.rb b/backend/app/models/tournament_group.rb new file mode 100644 index 0000000..61b4098 --- /dev/null +++ b/backend/app/models/tournament_group.rb @@ -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 diff --git a/backend/app/models/tournament_participant.rb b/backend/app/models/tournament_participant.rb new file mode 100644 index 0000000..658f588 --- /dev/null +++ b/backend/app/models/tournament_participant.rb @@ -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 + diff --git a/backend/app/models/tournament_round.rb b/backend/app/models/tournament_round.rb new file mode 100644 index 0000000..90b6b79 --- /dev/null +++ b/backend/app/models/tournament_round.rb @@ -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 diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 5979013..bc73997 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -12,16 +12,19 @@ class User < ApplicationRecord has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club has_many :stream_sessions, dependent: :nullify has_many :stream_concurrency_violations, dependent: :nullify + has_many :tournament_broadcast_assignments, dependent: :destroy def manageable_teams staff_ids = teams.select(:id) - owner_ids = Team.where(club_id: owned_clubs.select(:id)).select(:id) + owner_ids = Team.visible.where(club_id: owned_clubs.select(:id)).select(:id) Team.where(id: staff_ids).or(Team.where(id: owner_ids)) end # Squadre da cui l'utente può programmare partite e avviare lo streaming (app). def streamable_teams - manageable_teams.includes(:club).select { |team| can_stream_for?(team) } + listed = manageable_teams.includes(:club).select { |team| can_stream_for?(team) } + owned_broadcast = Team.tournament_broadcast.where(club_id: owned_clubs.select(:id)).includes(:club) + (listed + owned_broadcast.to_a).uniq end def club_admin?(club) @@ -29,6 +32,7 @@ class User < ApplicationRecord end def can_schedule_for?(team) + return false if team.tournament_broadcast? return true if team.club&.owned_by?(self) membership = user_teams.find_by(team: team) @@ -38,7 +42,7 @@ class User < ApplicationRecord end def schedulable_teams_for(club) - teams = club.teams.order(:name).to_a + teams = club.teams.visible.order(:name).to_a return teams if club.owned_by?(self) teams.select { |team| can_schedule_for?(team) } @@ -55,6 +59,14 @@ class User < ApplicationRecord Teams::StaffCoverage.new(team).covers_both_roles?(membership) end + def can_broadcast_match?(match) + return true if match.team.club&.owned_by?(self) + return false unless can_stream_for?(match.team) + return true unless match.tournament_match? + + tournament_broadcast_assignments.exists?(match_id: match.id) + end + def staff_role_for(team) return "owner" if team.club&.owned_by?(self) diff --git a/backend/app/services/recordings/pull_from_cloud_node.rb b/backend/app/services/recordings/pull_from_cloud_node.rb index 7cd2546..a2722d4 100644 --- a/backend/app/services/recordings/pull_from_cloud_node.rb +++ b/backend/app/services/recordings/pull_from_cloud_node.rb @@ -35,9 +35,9 @@ module Recordings FileUtils.remove_entry(dest) return nil end - unless res.is_a?(Net::HTTPSuccess) && res.body.present? + unless res.is_a?(Net::HTTPSuccess) && body_present?(res.body) FileUtils.remove_entry(dest) - raise Error, "agent GET recordings HTTP #{res.code} #{res.body.to_s.truncate(200)}" + raise Error, "agent GET recordings HTTP #{res.code} #{safe_body_snippet(res.body)}" end tar_path = File.join(dest, "recordings.tar.gz") @@ -84,6 +84,14 @@ module Recordings ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret" end + def body_present?(body) + body && !body.empty? + end + + def safe_body_snippet(body) + body.to_s.dup.force_encoding("UTF-8").scrub[0, 200] + end + def unpack!(tar_path, dest) ok = system("tar", "-xzf", tar_path, "-C", dest, out: File::NULL, err: File::NULL) raise Error, "tar extract failed" unless ok diff --git a/backend/app/services/sessions/stop.rb b/backend/app/services/sessions/stop.rb index 453bdc5..ab8cebf 100644 --- a/backend/app/services/sessions/stop.rb +++ b/backend/app/services/sessions/stop.rb @@ -15,6 +15,11 @@ module Sessions Recordings::UploadJob.perform_async(@session.id) if recording&.status == "processing" remove_mediamtx_paths! + begin + Tournaments::CaptureStreamResult.call(@session) + rescue StandardError => e + Rails.logger.warn("[Sessions::Stop] tournament result: #{e.class}: #{e.message}") + end log_event("ended") SessionChannel.broadcast_message(@session, { type: "stream_event", event: "ended" }) @session diff --git a/backend/app/services/teams/public_directory.rb b/backend/app/services/teams/public_directory.rb index 0273bac..97624ad 100644 --- a/backend/app/services/teams/public_directory.rb +++ b/backend/app/services/teams/public_directory.rb @@ -45,7 +45,7 @@ module Teams end def base_teams - scope = Team.includes(:club).where(id: active_team_ids) + scope = Team.visible.includes(:club).where(id: active_team_ids) scope = scope.where(sport_key: @sport) if @sport scope = apply_search(scope) if @q.present? scope.to_a diff --git a/backend/app/services/tournaments/capture_stream_result.rb b/backend/app/services/tournaments/capture_stream_result.rb new file mode 100644 index 0000000..950ba15 --- /dev/null +++ b/backend/app/services/tournaments/capture_stream_result.rb @@ -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 diff --git a/backend/app/services/tournaments/compose_invite_email.rb b/backend/app/services/tournaments/compose_invite_email.rb new file mode 100644 index 0000000..b0c705a --- /dev/null +++ b/backend/app/services/tournaments/compose_invite_email.rb @@ -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 +

#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.hello"))}

+

#{intro}

+

#{cta}

+

#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.link_fallback"))}
#{LINK_TOKEN}

+

#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.steps"))}

+

#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.expiry", date: EXPIRY_TOKEN))}

+

#{CGI.escapeHTML(I18n.t("mailers.tournament_invite.ignore"))}

+ 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) + %(

#{label}

#{url}

) + end + end +end diff --git a/backend/app/services/tournaments/create.rb b/backend/app/services/tournaments/create.rb new file mode 100644 index 0000000..e5317d5 --- /dev/null +++ b/backend/app/services/tournaments/create.rb @@ -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 diff --git a/backend/app/services/tournaments/destroy.rb b/backend/app/services/tournaments/destroy.rb new file mode 100644 index 0000000..1fa7ade --- /dev/null +++ b/backend/app/services/tournaments/destroy.rb @@ -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 diff --git a/backend/app/services/tournaments/ensure_broadcast_team.rb b/backend/app/services/tournaments/ensure_broadcast_team.rb new file mode 100644 index 0000000..e4d4d8b --- /dev/null +++ b/backend/app/services/tournaments/ensure_broadcast_team.rb @@ -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 diff --git a/backend/app/services/tournaments/entitlements.rb b/backend/app/services/tournaments/entitlements.rb new file mode 100644 index 0000000..fc17b97 --- /dev/null +++ b/backend/app/services/tournaments/entitlements.rb @@ -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 diff --git a/backend/app/services/tournaments/fill_knockout_sources.rb b/backend/app/services/tournaments/fill_knockout_sources.rb new file mode 100644 index 0000000..fd6ffe4 --- /dev/null +++ b/backend/app/services/tournaments/fill_knockout_sources.rb @@ -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 diff --git a/backend/app/services/tournaments/generate_group_matches.rb b/backend/app/services/tournaments/generate_group_matches.rb new file mode 100644 index 0000000..c7c009d --- /dev/null +++ b/backend/app/services/tournaments/generate_group_matches.rb @@ -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 diff --git a/backend/app/services/tournaments/generate_slug.rb b/backend/app/services/tournaments/generate_slug.rb new file mode 100644 index 0000000..f932474 --- /dev/null +++ b/backend/app/services/tournaments/generate_slug.rb @@ -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 diff --git a/backend/app/services/tournaments/grant_broadcast_access.rb b/backend/app/services/tournaments/grant_broadcast_access.rb new file mode 100644 index 0000000..b26429b --- /dev/null +++ b/backend/app/services/tournaments/grant_broadcast_access.rb @@ -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 diff --git a/backend/app/services/tournaments/invite.rb b/backend/app/services/tournaments/invite.rb new file mode 100644 index 0000000..6a5a18d --- /dev/null +++ b/backend/app/services/tournaments/invite.rb @@ -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 diff --git a/backend/app/services/tournaments/propose_knockout.rb b/backend/app/services/tournaments/propose_knockout.rb new file mode 100644 index 0000000..8920c88 --- /dev/null +++ b/backend/app/services/tournaments/propose_knockout.rb @@ -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 diff --git a/backend/app/services/tournaments/record_result.rb b/backend/app/services/tournaments/record_result.rb new file mode 100644 index 0000000..7a92920 --- /dev/null +++ b/backend/app/services/tournaments/record_result.rb @@ -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 diff --git a/backend/app/services/tournaments/revoke_assignment.rb b/backend/app/services/tournaments/revoke_assignment.rb new file mode 100644 index 0000000..f94fb54 --- /dev/null +++ b/backend/app/services/tournaments/revoke_assignment.rb @@ -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 diff --git a/backend/app/services/tournaments/schedule_match.rb b/backend/app/services/tournaments/schedule_match.rb new file mode 100644 index 0000000..915cb71 --- /dev/null +++ b/backend/app/services/tournaments/schedule_match.rb @@ -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 diff --git a/backend/app/services/tournaments/standings.rb b/backend/app/services/tournaments/standings.rb new file mode 100644 index 0000000..5646dfa --- /dev/null +++ b/backend/app/services/tournaments/standings.rb @@ -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 diff --git a/backend/app/services/tournaments/swap_sides.rb b/backend/app/services/tournaments/swap_sides.rb new file mode 100644 index 0000000..9cae843 --- /dev/null +++ b/backend/app/services/tournaments/swap_sides.rb @@ -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 diff --git a/backend/app/services/tournaments/sync_assignments.rb b/backend/app/services/tournaments/sync_assignments.rb new file mode 100644 index 0000000..7790b8f --- /dev/null +++ b/backend/app/services/tournaments/sync_assignments.rb @@ -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 diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index c417a68..aeeca7c 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -10,7 +10,7 @@ <%= render "shared/analytics_suppress" %> <%= yield :head %> - + data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <%= render "shared/cookie_banner" %> @@ -23,6 +23,8 @@ <%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %> + + diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index dc259fe..590c7b1 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -8,7 +8,7 @@ <%= render "shared/meta_tags" %> <%= render "shared/analytics_suppress" %> - + <%= yield :head %> diff --git a/backend/app/views/public/clubs/show.html.erb b/backend/app/views/public/clubs/show.html.erb index f74020c..861e4e6 100644 --- a/backend/app/views/public/clubs/show.html.erb +++ b/backend/app/views/public/clubs/show.html.erb @@ -21,6 +21,15 @@ <%= link_to t("club.dashboard.edit_club"), public_edit_club_path(@club), class: "btn btn-secondary" %> <%= link_to t("club.dashboard.subscription"), public_club_billing_path(@club), class: "btn btn-primary" %> <%= link_to t("club.dashboard.new_team"), public_new_club_team_path(@club), class: "btn btn-secondary" %> + <% if Tournaments::Entitlements.new(@club).premium_full? %> + <%= link_to t("club.dashboard.tournaments"), public_club_tournaments_path(@club), class: "btn btn-secondary" %> + <% else %> + <%= link_to public_club_billing_path(@club), class: "btn btn-secondary btn-gated", + title: t("club.dashboard.tournaments_locked_hint") do %> + <%= t("club.dashboard.tournaments") %> + + <% end %> + <% end %> <%= link_to t("club.dashboard.live_streams"), public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %> <% if @entitlements&.can_access_recordings? %> <%= link_to t("club.dashboard.replay_archive"), public_club_recordings_path(@club), class: "btn btn-secondary" %> diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb index b07d377..7a33c74 100644 --- a/backend/app/views/public/invitations/show.html.erb +++ b/backend/app/views/public/invitations/show.html.erb @@ -1,13 +1,21 @@ <% content_for :title, t("auth.invitation.meta_title") %> <% content_for :meta_description, t("auth.invitation.meta_description") %> <% content_for :robots, "noindex, nofollow" %> +<% + display_name = if @tournament_invitation + @tournament_invitation.tournament.name + else + @invitation.team.name + end + email = (@invitation || @tournament_invitation).email +%>
-

<%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %>

-

<%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %>

+

<%= raw t("auth.invitation.title_html", team_name: display_name) %>

+

<%= raw t("auth.invitation.role_notice_html", email: email) %>

- <%= raw t("auth.invitation.instructions_html", email: @invitation.email) %> + <%= raw t("auth.invitation.instructions_html", email: email) %>

<% if logged_in? %> <%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> @@ -16,7 +24,7 @@ "auth.invitation.login_or_signup_html", login_link: link_to(t("auth.invitation.login_link"), public_login_path), signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path), - email: @invitation.email + email: email ) %>

<%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> <% end %> diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb index dea1b8d..156bac3 100644 --- a/backend/app/views/public/live/index.html.erb +++ b/backend/app/views/public/live/index.html.erb @@ -118,13 +118,28 @@
<% end %> - <% if @sessions.empty? %> - <% if @upcoming_matches.any? %> -
-

<%= t("live.index.empty_soft_title") %>

-

<%= t("live.index.empty_soft_body") %>

-
- <% elsif @club %> + <% if @sessions.empty? && @upcoming_matches.any? %> +
+

<%= t("live.index.empty_soft_title") %>

+

<%= t("live.index.empty_soft_body") %>

+
+ <% end %> + + <% if @public_tournaments.any? %> +

"><%= t("tournaments.directory.section_on_live") %>

+

<%= t("tournaments.directory.live_hint") %>

+
+ <% @public_tournaments.each do |tournament| %> + <%= render "public/tournament_pages/directory_card", tournament: tournament %> + <% end %> +
+

+ <%= link_to t("tournaments.directory.all_link"), public_tournament_pages_path %> +

+ <% end %> + + <% if @sessions.empty? && @upcoming_matches.empty? && @public_tournaments.blank? %> + <% if @club %>

<%= t("live.index.empty_club_title", name: @club.name) %>

diff --git a/backend/app/views/public/tournament_pages/_board_row.html.erb b/backend/app/views/public/tournament_pages/_board_row.html.erb new file mode 100644 index 0000000..20063be --- /dev/null +++ b/backend/app/views/public/tournament_pages/_board_row.html.erb @@ -0,0 +1,48 @@ +<% state = tournament_public_board_state(match) %> +<% live_session = tournament_public_live_session(match) %> +<% recording = tournament_public_recording(match) %> +<% score = tournament_public_score_label(match) %> + + + <% if match.scheduled_at %> + <%= match.scheduled_at.in_time_zone.strftime("%H:%M") %> + <% else %> + — + <% end %> + + <%= match.court.presence || "—" %> + +

+ <%= tournament_team_chip(match.home_participant, name: match.home_display_name) %> + + <%= tournament_team_chip(match.away_participant, name: match.away_display_name) %> +
+ <% if match.tournament_group || match.tournament_round %> +

+ <%= [match.tournament_group&.name, match.tournament_round&.name].compact.join(" · ") %> +

+ <% end %> + + + <% if score.present? %> + <%= score %> + <% else %> + — + <% end %> + + + + + <%= t("tournaments.page.state.#{state}") %> + + + + <% if live_session %> + <%= link_to t("tournaments.page.watch_live"), public_live_path(live_session), class: "tournament-board__cta tournament-board__cta--live" %> + <% elsif recording %> + <%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %> + <% else %> + <%= t("tournaments.page.no_media") %> + <% end %> + + diff --git a/backend/app/views/public/tournament_pages/_directory_card.html.erb b/backend/app/views/public/tournament_pages/_directory_card.html.erb new file mode 100644 index 0000000..ffc42dc --- /dev/null +++ b/backend/app/views/public/tournament_pages/_directory_card.html.erb @@ -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 %> +
+ <% if logo.present? %> + <%= image_tag logo, alt: "", class: "team-directory-card__photo team-directory-card__photo--logo", width: 72, height: 72 %> + <% else %> + + <% end %> +
+
+

<%= club.name %>

+

<%= tournament.name %>

+

+ <%= tournament.sport_label %> + · <%= l(tournament.starts_on) %> – <%= l(tournament.ends_on) %> + <% if tournament.venue.present? %> · <%= tournament.venue %><% end %> +

+
+ <% if tournament.archived? %> + <%= t("tournaments.status.archived") %> + <% end %> + <%= t("tournaments.directory.follow") %> +
+
+<% end %> diff --git a/backend/app/views/public/tournament_pages/_tab_risultati.html.erb b/backend/app/views/public/tournament_pages/_tab_risultati.html.erb new file mode 100644 index 0000000..ceb1b25 --- /dev/null +++ b/backend/app/views/public/tournament_pages/_tab_risultati.html.erb @@ -0,0 +1,62 @@ +
+

<%= t("tournaments.page.tabs.risultati") %>

+

<%= t("tournaments.page.board_hint") %>

+ <% if @matches.any? %> + <% tournament_matches_grouped_by_date(@matches).each do |date, matches| %> +

+ <%= date ? l(date, format: :long) : t("tournaments.hub.stream_plan_unscheduled") %> +

+
+ + + + + + + + + + + + + <% matches.each do |match| %> + <%= render "public/tournament_pages/board_row", match: match %> + <% end %> + +
<%= t("tournaments.hub.datetime") %><%= t("tournaments.hub.court") %><%= t("tournaments.hub.stream_plan_match") %><%= t("tournaments.page.score") %><%= t("tournaments.hub.stream_plan_live_col") %>
+
+ <% end %> + <% else %> +

<%= t("tournaments.hub.no_matches") %>

+ <% end %> +
+ +<% if @recordings.any? %> +
+

<%= t("tournaments.page.section_replays") %>

+
+ <% @recordings.each do |rec| %> + <% match = rec.stream_session.match %> + <%= link_to public_replay_path(rec.stream_session_id), class: "replay-card" do %> +
+ <% if rec.thumbnail_url %> + + <% else %> + + <% end %> + <% if rec.duration_label.present? %> + <%= rec.duration_label %> + <% end %> + +
+
+ <%= rec.title.presence || match.matchup_label %> +

<%= match.matchup_label %>

+
+ <% end %> + <% end %> +
+
+<% end %> diff --git a/backend/app/views/public/tournament_pages/_tab_tabellone.html.erb b/backend/app/views/public/tournament_pages/_tab_tabellone.html.erb new file mode 100644 index 0000000..ad61270 --- /dev/null +++ b/backend/app/views/public/tournament_pages/_tab_tabellone.html.erb @@ -0,0 +1,60 @@ +<% visible_rounds = @rounds.select { |round| (@matches_by_round[round.id] || []).any? } %> + +
+

<%= t("tournaments.page.tabs.tabellone") %>

+ + <% @groups.each do |group| %> +

<%= group.name %> — <%= t("tournaments.hub.standings") %>

+
+ + + + + + + + + + + + <% @standings_by_group[group].each_with_index do |row, idx| %> + + + + + + + + <% end %> + +
<%= t("tournaments.hub.played") %><%= t("tournaments.hub.won") %><%= t("tournaments.hub.lost") %><%= t("tournaments.hub.points") %>
<%= idx + 1 %>. <%= tournament_team_chip(row.participant) %><%= row.played %><%= row.won %><%= row.lost %><%= row.points %>
+
+ <% end %> + + <% visible_rounds.each do |round| %> +

<%= round.name %>

+
+ + + + + + + + + + + + + <% @matches_by_round[round.id].each do |match| %> + <%= render "public/tournament_pages/board_row", match: match %> + <% end %> + +
<%= t("tournaments.hub.datetime") %><%= t("tournaments.hub.court") %><%= t("tournaments.hub.stream_plan_match") %><%= t("tournaments.page.score") %><%= t("tournaments.hub.stream_plan_live_col") %>
+
+ <% end %> + + <% if @groups.none? && visible_rounds.none? %> +

<%= t("tournaments.page.bracket_empty") %>

+ <% end %> +
diff --git a/backend/app/views/public/tournament_pages/index.html.erb b/backend/app/views/public/tournament_pages/index.html.erb new file mode 100644 index 0000000..e2cf882 --- /dev/null +++ b/backend/app/views/public/tournament_pages/index.html.erb @@ -0,0 +1,20 @@ +<% content_for :title, t("tournaments.directory.meta_title") %> +<% content_for :meta_description, t("tournaments.directory.meta_description") %> +<% content_for :canonical_url, seo_absolute_url(public_tournament_pages_path) %> + +
+

<%= t("tournaments.directory.heading") %>

+

<%= t("tournaments.directory.hint") %>

+ + <% if @tournaments.any? %> +
+ <% @tournaments.each do |tournament| %> + <%= render "public/tournament_pages/directory_card", tournament: tournament %> + <% end %> +
+ <% else %> +
+

<%= t("tournaments.directory.empty") %>

+
+ <% end %> +
diff --git a/backend/app/views/public/tournament_pages/show.html.erb b/backend/app/views/public/tournament_pages/show.html.erb new file mode 100644 index 0000000..7ae53d5 --- /dev/null +++ b/backend/app/views/public/tournament_pages/show.html.erb @@ -0,0 +1,82 @@ +<% content_for :title, t("tournaments.page.title", name: @tournament.name, club: @club.name) %> +<% content_for :robots, @tournament.published? ? "index, follow" : "noindex, nofollow" %> +<% content_for :canonical_url, seo_absolute_url(public_tournament_page_path(@tournament.slug)) if @tournament.published? %> + +
+

+ <%= link_to t("tournaments.page.back_to_list"), public_tournament_pages_path, class: "back-link" %> +

+ +
+ <% logo = @tournament.effective_logo_url %> + <% if logo.present? %> + <%= image_tag logo, alt: "", class: "tournament-public-hero__logo", width: 56, height: 56 %> + <% else %> + + <% end %> +
+

<%= @club.name %>

+

<%= @tournament.name %>

+

+ <%= @tournament.sport_label %> + · <%= l(@tournament.starts_on) %> – <%= l(@tournament.ends_on) %> + <% if @tournament.venue.present? %> · <%= @tournament.venue %><% end %> +

+ <% if @owner_preview %> +

<%= t("tournaments.page.draft_banner") %>

+ <% end %> +
+ <% if @owner_manage %> + <%= link_to t("tournaments.index.open"), public_tournament_path(@tournament), class: "btn btn-secondary tournament-public-hero__manage" %> + <% end %> +
+ + <% if @live_sessions.any? %> +
+

<%= t("tournaments.page.section_live") %>

+
+ <% @live_sessions.each do |session| %> + <% match = session.match %> + <% on_air = @online_paths.include?(session.mediamtx_path_name) %> +
+ <%= live_match_card_heading(match, link_team: false) %> +

+ <% if match.court_or_location.present? %><%= match.court_or_location %> · <% end %> + Match Live TV +

+ <% if session.score_state %> +

+ <%= live_score_sets_label(session.score_state, match) %> + <% if live_score_partials_label(session.score_state).present? %> + <%= t("score.partials_prefix", value: live_score_partials_label(session.score_state)) %> + <% end %> + <%= session.score_state.home_points %> - <%= session.score_state.away_points %> +

+ <% end %> +
+ <% if on_air %> + <%= t("live.index.badge_on_air") %> + <% elsif session.paused? %> + <%= t("live.index.badge_paused") %> + <% else %> + <%= t("live.index.badge_live") %> + <% end %> +
+ <%= link_to t("tournaments.page.watch_live"), public_live_path(session), class: "btn-watch" %> +
+ <% end %> +
+
+ <% end %> + + + + <%= render "public/tournament_pages/tab_#{@tab}" %> +
diff --git a/backend/app/views/public/tournaments/_match_side_select.html.erb b/backend/app/views/public/tournaments/_match_side_select.html.erb new file mode 100644 index 0000000..bd34648 --- /dev/null +++ b/backend/app/views/public/tournaments/_match_side_select.html.erb @@ -0,0 +1,10 @@ +<% participant = match.public_send("#{side}_participant") %> +<% if @writable && !tournament_match_sides_locked?(match) %> + <%= select_tag "match[#{side}_participant_id]", + options_from_collection_for_select(@participants, :id, :name, participant&.id), + include_blank: tournament_side_blank_label(match, side), + form: form_id, + class: "tournament-match-form__team" %> +<% else %> + <%= tournament_team_chip(participant, name: match.public_send("#{side}_display_name")) %> +<% end %> diff --git a/backend/app/views/public/tournaments/_match_streaming.html.erb b/backend/app/views/public/tournaments/_match_streaming.html.erb new file mode 100644 index 0000000..7a5ac53 --- /dev/null +++ b/backend/app/views/public/tournaments/_match_streaming.html.erb @@ -0,0 +1,64 @@ +<% assignments = match.broadcast_assignments.to_a %> +<% pending = tournament_pending_invites_for(match) %> +<% tab = local_assigns.fetch(:return_tab, "dirette") %> +<% invite = local_assigns.fetch(:allow_invite, false) %> + + <% assignments.each do |assignment| %> + <% label = tournament_streaming_operator_label(assignment.user) %> +
+ + <%= label %> + <% if writable %> + <%= button_to public_tournament_assignment_path(@tournament, assignment), + method: :delete, + class: "tournament-streaming-chip__action", + title: t("tournaments.hub.streaming_revoke"), + form: { + class: "tournament-streaming-chip__form", + data: { turbo_confirm: t("tournaments.hub.streaming_revoke_confirm", name: label) } + } do %> + + <%= t("tournaments.hub.streaming_revoke") %> + <% end %> + <% end %> +
+ <% end %> + + <% pending.each do |invitation| %> +
+ + <%= invitation.email %> + <% if writable %> + <%= button_to public_tournament_invitation_path(@tournament, invitation), + method: :delete, + params: { tab: tab }, + class: "tournament-streaming-chip__action", + title: t("team.streaming_staff.cancel_invitation"), + form: { + class: "tournament-streaming-chip__form", + data: { turbo_confirm: tournament_invite_cancel_confirm(invitation) } + } do %> + + <%= t("team.streaming_staff.cancel_invitation") %> + <% end %> + <% end %> +
+ <% end %> + + <% if writable && invite && assignments.empty? && pending.empty? %> + <%= form_with url: public_tournament_invitations_path(@tournament), method: :post, html: { class: "tournament-streaming-form" } do %> + <%= hidden_field_tag :from, "calendar" %> + <%= hidden_field_tag :tab, tab %> + <%= hidden_field_tag "match_ids[]", match.id %> + <%= email_field_tag :email, nil, + required: true, + autocomplete: "email", + placeholder: t("tournaments.hub.invite_email"), + class: "tournament-streaming-form__email", + id: "stream_email_#{match.id}" %> + <%= submit_tag t("tournaments.hub.streaming_invite"), class: "btn btn-secondary tournament-match-form__save" %> + <% end %> + <% elsif assignments.empty? && pending.empty? %> + + <% end %> + diff --git a/backend/app/views/public/tournaments/_tab_calendario.html.erb b/backend/app/views/public/tournaments/_tab_calendario.html.erb new file mode 100644 index 0000000..f54ff8d --- /dev/null +++ b/backend/app/views/public/tournaments/_tab_calendario.html.erb @@ -0,0 +1,140 @@ +
+ <% if @writable && @tournament.uses_groups? %> +

<%= t("tournaments.hub.generate_hint") %>

+ <%= button_to t("tournaments.hub.generate_group_matches"), + public_generate_group_matches_tournament_path(@tournament), + method: :post, class: "btn btn-secondary" %> + <% end %> + + <% if @matches.any? %> +

<%= t("tournaments.hub.calendar_edit_hint") %>

+
+ "> + + + + + + + + + + + + + + <% @matches.each do |match| %> + <% form_id = "match-edit-#{match.id}" %> + <% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %> + <%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %> + <%= hidden_field_tag :tab, @tab, form: form_id %> + <% end %> + + <% if @writable %> + + + + + + + + + <% else %> + + + + + + + + + <% end %> + + <% end %> + +
<%= t("tournaments.hub.datetime") %><%= t("tournaments.hub.court") %><%= t("tournaments.hub.round") %><%= t("tournaments.hub.home") %><%= t("tournaments.hub.away") %><%= t("tournaments.hub.result") %>
+ <%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at), + id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %> + + <%= select_tag "match[court]", options_for_select(courts, match.court), + id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %> + <%= tournament_phase_label(match) %><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %> + <%= button_to public_swap_tournament_match_path(@tournament, match), method: :post, + class: "tournament-match-swap__btn", + title: t("tournaments.hub.swap_sides"), + form: { class: "tournament-match-swap__form", data: { turbo: false } } do %> + + <%= t("tournaments.hub.swap_sides") %> + <% end %> + <%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %> + <% if match.played? || match.result_status.to_s.start_with?("walkover") %> + <%= match.home_score %>–<%= match.away_score %> + <% else %> + + <%= number_field_tag :home_score, match.home_score, id: "home_score_#{match.id}", form: form_id, data: { swap_score: "home" } %> + + <%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %> + + <% end %> + + <%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %> + <% if match.deletable? %> + <%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match), + method: :delete, class: "btn btn-secondary tournament-match-form__save", + form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %> + <% end %> + <%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %><%= match.court || "—" %><%= tournament_phase_label(match) %><%= tournament_team_chip(match.home_participant, name: match.home_display_name) %><%= tournament_team_chip(match.away_participant, name: match.away_display_name) %> + <% if match.home_score.present? %> + <%= match.home_score %>–<%= match.away_score %> + <% else %> + — + <% end %> +
+
+ <% else %> +

<%= t("tournaments.hub.no_matches") %>

+ <% end %> + + <% if @writable %> +
+ <%= t("tournaments.hub.schedule_another") %> + <%= form_with url: public_tournament_matches_path(@tournament), method: :post, html: { class: "tournament-form", style: "margin-top:16px" } do %> +
+ <%= label_tag "match[home_participant_id]", t("tournaments.hub.home") %> + <%= select_tag "match[home_participant_id]", + options_from_collection_for_select(@participants, :id, :name), include_blank: t("tournaments.tbd") %> +
+
+ <%= label_tag "match[away_participant_id]", t("tournaments.hub.away") %> + <%= select_tag "match[away_participant_id]", + options_from_collection_for_select(@participants, :id, :name), include_blank: t("tournaments.tbd") %> +
+
+ <%= label_tag "match[court]", t("tournaments.hub.court") %> + <%= select_tag "match[court]", options_for_select(@tournament.court_list) %> +
+
+ <%= label_tag "match[scheduled_at]", t("tournaments.hub.datetime") %> + <%= datetime_local_field_tag "match[scheduled_at]", nil, required: true %> +
+ <% if @groups.any? %> +
+ <%= label_tag "match[tournament_group_id]", t("tournaments.hub.group") %> + <%= select_tag "match[tournament_group_id]", + options_from_collection_for_select(@groups, :id, :name), include_blank: true %> +
+ <% end %> + <% if @rounds.any? %> +
+ <%= label_tag "match[tournament_round_id]", t("tournaments.hub.round") %> + <%= select_tag "match[tournament_round_id]", + options_from_collection_for_select(@rounds, :id, :name), include_blank: t("tournaments.hub.no_round") %> +
+ <% end %> +
+ <%= submit_tag t("tournaments.hub.schedule_match"), class: "btn btn-primary" %> +
+ <% end %> +
+ <% end %> +
diff --git a/backend/app/views/public/tournaments/_tab_dirette.html.erb b/backend/app/views/public/tournaments/_tab_dirette.html.erb new file mode 100644 index 0000000..5b37a5b --- /dev/null +++ b/backend/app/views/public/tournaments/_tab_dirette.html.erb @@ -0,0 +1,170 @@ +<% coverage = tournament_streaming_coverage(@matches) %> +<% live_counts = tournament_streaming_live_counts(@matches) %> +
+
+
+
+

<%= t("tournaments.hub.stream_plan_title") %>

+

<%= t("tournaments.hub.stream_plan_hint") %>

+
+ <% if @writable && @matches.any? %> + <%= t("tournaments.hub.stream_plan_invite_title") %> + <% end %> +
+ + <% if @matches.any? %> +
+ <% %i[live waiting_time waiting_operator ended uncovered].each do |state| %> + <% count = live_counts[state].to_i %> + <% next if count.zero? %> + + <%= t("tournaments.hub.stream_plan_live_count.#{state}", count: count) %> + + <% end %> + <% if @writable && coverage[:open].positive? %> + + <% end %> +
+ + <% tournament_matches_grouped_by_date(@matches).each do |date, matches| %> +

+ <%= date ? l(date, format: :long) : t("tournaments.hub.stream_plan_unscheduled") %> +

+
+ + + + <% if @writable %><% end %> + + + + + + + + + <% matches.each do |match| %> + <% state = tournament_broadcast_live_state(match) %> + <% uncovered = state == :uncovered %> + + <% if @writable %> + + <% end %> + + + + + <%= render "public/tournaments/match_streaming", match: match, writable: @writable, allow_invite: false, return_tab: "dirette" %> + + <% end %> + +
<%= t("tournaments.hub.datetime") %><%= t("tournaments.hub.court") %><%= t("tournaments.hub.stream_plan_match") %><%= t("tournaments.hub.stream_plan_live_col") %><%= t("tournaments.hub.stream_plan_operator") %>
+ <% if uncovered %> + + <% end %> + <%= match.scheduled_at ? match.scheduled_at.in_time_zone.strftime("%H:%M") : "—" %><%= match.court.presence || "—" %> +
+ <%= tournament_team_chip(match.home_participant, name: match.home_display_name) %> + + <%= tournament_team_chip(match.away_participant, name: match.away_display_name) %> +
+
+ + + <%= t("tournaments.hub.stream_plan_live.#{state}") %> + +
+
+ <% end %> + <% else %> +

<%= t("tournaments.hub.no_matches") %>

+ <% end %> +
+ + <% if @writable %> +
+

<%= t("tournaments.hub.stream_plan_invite_title") %>

+

<%= t("tournaments.hub.stream_plan_invite_hint") %>

+ + <%= form_with url: public_tournament_invitations_path(@tournament), method: :post, + html: { id: "tournament-stream-invite", class: "tournament-form tournament-invite-form tournament-stream-plan__invite" } do %> + <%= hidden_field_tag :tab, "dirette" %> +
+ <%= label_tag :email, t("tournaments.hub.invite_email") %> + <%= email_field_tag :email, @tournament.invite_draft_email, required: true, autocomplete: "email" %> +
+
+ <%= label_tag :note, t("tournaments.hub.invite_note") %> + <%= text_area_tag :note, @tournament.invite_draft_note, rows: 2, maxlength: 2000, data: { invite_note: true }, + placeholder: t("tournaments.hub.invite_note_placeholder") %> +
+
+ <%= label_tag :court, t("tournaments.hub.court") %> + <%= select_tag :court, options_for_select(@tournament.court_list) %> +
+
+ <%= label_tag :on_date, t("tournaments.hub.datetime") %> + <%= date_field_tag :on_date, @tournament.starts_on %> +
+

<%= t("tournaments.hub.stream_plan_court_day_hint") %>

+ +
+

<%= t("tournaments.hub.invite_email_body") %>

+

<%= t("tournaments.hub.invite_email_hint") %>

+ <% if @tournament.invite_draft_saved_at.present? %> +

<%= t("tournaments.hub.invite_draft_hint", time: l(@tournament.invite_draft_saved_at, format: :short)) %>

+ <% end %> +
" + data-msg-size="<%= t("tournaments.hub.invite_image_too_big") %>" + data-msg-uploading="<%= t("tournaments.hub.invite_image_uploading") %>" + data-msg-failed="<%= t("tournaments.hub.invite_image_failed") %>" + data-msg-link="<%= t("tournaments.hub.invite_editor_link_prompt") %>"> + +
+
+ <%= raw tournament_invite_editor_html(@tournament, current_user) %> +
+ +
+ +
+ <%= hidden_field_tag :email_html, "", id: "invite-email-html", data: { invite_html: true } %> +
+ +
+ <%= button_tag t("tournaments.hub.invite_save"), type: "submit", name: "intent", value: "save", + formnovalidate: true, class: "btn btn-secondary" %> + <%= submit_tag t("tournaments.hub.stream_plan_invite_selected"), class: "btn btn-primary" %> + <%= button_tag t("tournaments.hub.stream_plan_invite_court"), type: "submit", name: "whole_court", value: "1", class: "btn btn-secondary" %> +
+ <% end %> + + <% if flash[:invite_url].present? %> + + <% end %> +
+ <% end %> +
diff --git a/backend/app/views/public/tournaments/_tab_squadre.html.erb b/backend/app/views/public/tournaments/_tab_squadre.html.erb new file mode 100644 index 0000000..bb708c8 --- /dev/null +++ b/backend/app/views/public/tournaments/_tab_squadre.html.erb @@ -0,0 +1,100 @@ +
+

<%= t("tournaments.hub.squadre_edit_hint") %>

+ <% if @writable %> + <%= form_with url: public_tournament_participants_path(@tournament), method: :post, html: { class: "tournament-form" } do %> +
+ <%= label_tag "tournament_participant[name]", t("tournaments.hub.add_participant") %> + <%= text_field_tag "tournament_participant[name]", nil, required: true, placeholder: t("tournaments.hub.participant_name") %> +
+ <% if @groups.any? %> +
+ <%= label_tag "tournament_participant[group_id]", t("tournaments.hub.participant_group") %> + <%= select_tag "tournament_participant[group_id]", + options_for_select([[t("tournaments.hub.no_group"), ""]] + @groups.map { |g| [g.name, g.id] }) %> +
+ <% end %> +
+ <%= submit_tag t("tournaments.hub.add_participant"), class: "btn btn-primary" %> +
+ <% end %> + <% end %> + + <% if @participants.any? %> +
+ + + + + + + + + + + <% @participants.each do |participant| %> + <% form_id = "participant-edit-#{participant.id}" %> + <% logo = participant.effective_logo_url %> + <%= form_with url: public_tournament_participant_path(@tournament, participant), + method: :patch, + multipart: true, + html: { id: form_id, class: "visually-hidden" } do %> + <% end %> + + + + + + + <% end %> + +
<%= t("tournaments.hub.logo") %><%= t("tournaments.hub.participant_name") %><%= t("tournaments.hub.participant_group") %>
+ + + <% if @writable %> + <%= text_field_tag "tournament_participant[name]", participant.name, + id: "participant_name_#{participant.id}", + form: form_id, + required: true, + class: "tournament-squad-name" %> + <% else %> + <%= tournament_team_chip(participant) %> + <% end %> + + <% if @writable && @groups.any? %> + <%= select_tag "tournament_participant[group_id]", + options_for_select([[t("tournaments.hub.no_group"), ""]] + @groups.map { |g| [g.name, g.id] }, participant.group_id), + id: "participant_group_#{participant.id}", + form: form_id, + class: "tournament-squad-group" %> + <% else %> + <%= participant.group&.name || "—" %> + <% end %> + + <% if @writable %> + <%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %> + <%= button_to t("matches.index.delete"), public_tournament_participant_path(@tournament, participant), + method: :delete, class: "btn btn-secondary tournament-match-form__save", + form: { data: { turbo_confirm: participant.name } } %> + <% end %> +
+
+ <% end %> +
diff --git a/backend/app/views/public/tournaments/_tab_struttura.html.erb b/backend/app/views/public/tournaments/_tab_struttura.html.erb new file mode 100644 index 0000000..001cd73 --- /dev/null +++ b/backend/app/views/public/tournaments/_tab_struttura.html.erb @@ -0,0 +1,34 @@ +
+

<%= t("tournaments.form.formats.#{@tournament.format_kind}") %>

+ + <% if @tournament.uses_groups? %> +

<%= t("tournaments.hub.tabs.struttura") %>

+
    + <% @groups.each do |group| %> +
  • + <%= group.name %> + <% if @writable %> + <%= button_to t("matches.index.delete"), public_tournament_group_path(@tournament, group), + method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem;display:inline", + form: { data: { turbo_confirm: group.name }, style: "display:inline" } %> + <% end %> +
  • + <% end %> +
+ <% if @writable %> + <%= form_with url: public_tournament_groups_path(@tournament), method: :post do %> + <%= text_field_tag "tournament_group[name]", nil, placeholder: t("tournaments.hub.add_group") %> + <%= submit_tag t("tournaments.hub.add_group"), class: "btn btn-secondary" %> + <% end %> + <% end %> + <% end %> + + <% if @tournament.uses_knockout? %> +

<%= t("tournaments.hub.tabs.tabellone") %>

+
    + <% @rounds.each do |round| %> +
  • <%= round.name %>
  • + <% end %> +
+ <% end %> +
diff --git a/backend/app/views/public/tournaments/_tab_tabellone.html.erb b/backend/app/views/public/tournaments/_tab_tabellone.html.erb new file mode 100644 index 0000000..9b277b3 --- /dev/null +++ b/backend/app/views/public/tournaments/_tab_tabellone.html.erb @@ -0,0 +1,113 @@ +
+ <% if @writable && @tournament.uses_knockout? %> +
+

<%= t("tournaments.hub.knockout_how_title") %>

+
    +
  1. <%= t("tournaments.hub.knockout_how_groups") %>
  2. +
  3. <%= t("tournaments.hub.knockout_how_seed") %>
  4. +
  5. <%= t("tournaments.hub.knockout_how_advance") %>
  6. +
+ <% if @tournament.uses_groups? %> + <%= button_to t("tournaments.hub.propose_knockout"), + public_propose_knockout_tournament_path(@tournament), + method: :post, class: "btn btn-primary" %> + <% end %> +
+ <% end %> + + <% @groups.each do |group| %> +

<%= group.name %> — <%= t("tournaments.hub.standings") %>

+ <% rows = @standings_by_group[group] %> + <% if rows.any? %> + + + + + + + + + + + + <% rows.each_with_index do |row, idx| %> + + + + + + + + <% end %> + +
<%= t("tournaments.hub.played") %><%= t("tournaments.hub.won") %><%= t("tournaments.hub.lost") %><%= t("tournaments.hub.points") %>
<%= idx + 1 %>. <%= tournament_team_chip(row.participant) %><%= row.played %><%= row.won %><%= row.lost %><%= row.points %>
+ <% end %> + <% end %> + + <% @rounds.each do |round| %> +

<%= round.name %>

+ <% round_matches = @matches.select { |m| m.tournament_round_id == round.id } %> + <% if round_matches.any? %> +
+ + + + + + + + <% if @writable %><% end %> + + + + <% round_matches.each do |match| %> + <% form_id = "ko-edit-#{match.id}" %> + <%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %> + <%= hidden_field_tag :tab, "tabellone", form: form_id %> + <%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at), + id: "ko_at_#{match.id}", form: form_id %> + <% end %> + + + + + + <% if @writable %> + + <% end %> + + <% end %> + +
<%= t("tournaments.hub.datetime") %><%= t("tournaments.hub.home") %><%= t("tournaments.hub.away") %><%= t("tournaments.hub.result") %>
<%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %> + <%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %> + <% src = tournament_side_source_label(match, :home) %> + <% if src.present? && match.home_participant_id.blank? %> +
<%= src %>
+ <% end %> +
+ <%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %> + <% src = tournament_side_source_label(match, :away) %> + <% if src.present? && match.away_participant_id.blank? %> +
<%= src %>
+ <% end %> +
+ <% if tournament_match_sides_locked?(match) %> + <%= match.home_score %>–<%= match.away_score %> + <% elsif @writable %> + + <%= number_field_tag :home_score, match.home_score, id: "ko_home_score_#{match.id}", form: form_id %> + + <%= number_field_tag :away_score, match.away_score, id: "ko_away_score_#{match.id}", form: form_id %> + + <% else %> + — + <% end %> + + <%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %> +
+
+ <% else %> +

<%= t("tournaments.hub.knockout_round_empty") %>

+ <% end %> + <% end %> +
diff --git a/backend/app/views/public/tournaments/index.html.erb b/backend/app/views/public/tournaments/index.html.erb new file mode 100644 index 0000000..cc514a3 --- /dev/null +++ b/backend/app/views/public/tournaments/index.html.erb @@ -0,0 +1,61 @@ +<% content_for :title, t("tournaments.index.title", name: @club.name) %> +<% content_for :robots, "noindex, nofollow" %> + +
+ + +
+
+

<%= t("tournaments.index.heading") %>

+

<%= t("tournaments.index.lead") %>

+
+ <% if @can_create %> + <%= link_to t("tournaments.index.new"), public_new_club_tournament_path(@club), class: "btn btn-primary" %> + <% end %> +
+ + <% unless @can_create %> +
<%= t("tournaments.index.upgrade") %> <%= link_to t("club.dashboard.subscription"), public_club_billing_path(@club) %>
+ <% end %> + +
+ <% if @tournaments.any? %> +
+ + + + + + + + + + + <% @tournaments.each do |tournament| %> + + + + + + + <% end %> + +
<%= t("tournaments.index.col_name") %><%= t("tournaments.index.col_dates") %><%= t("tournaments.index.col_status") %>
<%= tournament.name %><%= l(tournament.starts_on) %> – <%= l(tournament.ends_on) %><%= t("tournaments.status.#{tournament.status}") %> +
+ <%= link_to t("tournaments.index.open"), public_tournament_path(tournament), class: "btn btn-secondary", style: "padding:8px 14px;font-size:0.9rem" %> + <% if tournament.published? %> + <%= link_to t("tournaments.hub.public_page"), public_tournament_page_path(tournament.slug), class: "btn btn-secondary", style: "padding:8px 14px;font-size:0.9rem", target: "_blank", rel: "noopener" %> + <% end %> + <%= button_to t("tournaments.index.delete"), public_tournament_path(tournament), method: :delete, + class: "btn btn-secondary", style: "padding:8px 14px;font-size:0.9rem", + form: { data: { turbo_confirm: t("tournaments.hub.delete_confirm", name: tournament.name), confirm_kind: "delete" } } %> +
+
+
+ <% else %> +

<%= t("tournaments.index.empty") %>

+ <% end %> +
+
diff --git a/backend/app/views/public/tournaments/new.html.erb b/backend/app/views/public/tournaments/new.html.erb new file mode 100644 index 0000000..2243dc3 --- /dev/null +++ b/backend/app/views/public/tournaments/new.html.erb @@ -0,0 +1,79 @@ +<% content_for :title, t("tournaments.new.title", name: @club.name) %> +<% content_for :robots, "noindex, nofollow" %> + +
+ +

<%= t("tournaments.new.heading") %>

+

<%= t("tournaments.index.lead") %>

+ +
+ <%= form_with model: @tournament, url: public_club_tournaments_path(@club), method: :post, html: { class: "tournament-form", multipart: true, data: { tournament_form: true } } do %> +
+ <%= label_tag "tournament[name]", t("tournaments.form.name") %> + <%= text_field_tag "tournament[name]", @tournament.name, required: true %> +
+ +
+ <%= label_tag "tournament[sport_key]", t("tournaments.form.sport") %> + <%= select_tag "tournament[sport_key]", + options_for_select(Sports::Catalog.as_api_list.map { |s| [s[:label], s[:key]] }, @tournament.sport_key) %> +
+ +
+ <%= label_tag "tournament[venue]", t("tournaments.form.venue") %> + <%= text_field_tag "tournament[venue]", @tournament.venue %> +
+ +
+ <%= label_tag "tournament[starts_on]", t("tournaments.form.starts_on") %> + <%= date_field_tag "tournament[starts_on]", @tournament.starts_on, required: true %> +
+ +
+ <%= label_tag "tournament[ends_on]", t("tournaments.form.ends_on") %> + <%= date_field_tag "tournament[ends_on]", @tournament.ends_on, required: true %> +
+ +
+ <%= label_tag "tournament[format_kind]", t("tournaments.form.format") %> + <%= select_tag "tournament[format_kind]", + options_for_select(Tournament::FORMAT_KINDS.map { |k| [t("tournaments.form.formats.#{k}"), k] }, @tournament.format_kind), + data: { tournament_format: true } %> +
+ +
+ <%= label_tag "tournament[knockout_size]", t("tournaments.form.knockout_size") %> + <%= select_tag "tournament[knockout_size]", options_for_select([2, 4, 8, 16], @tournament.knockout_size || 4) %> +
+ +
+ <%= label_tag "tournament[logo_file]", t("tournaments.form.logo") %> + <%= file_field_tag "tournament[logo_file]", accept: "image/png,image/jpeg,image/webp" %> +

<%= t("tournaments.form.logo_hint") %>

+
+ +
+ <%= label_tag "tournament[courts]", t("tournaments.form.courts") %> + <%= text_area_tag "tournament[courts]", Array(@tournament.courts).join("\n"), rows: 3 %> +
+ +
+ <%= submit_tag t("tournaments.new.submit"), class: "btn btn-primary" %> +
+ <% end %> +
+
+ diff --git a/backend/app/views/public/tournaments/show.html.erb b/backend/app/views/public/tournaments/show.html.erb new file mode 100644 index 0000000..f3886f6 --- /dev/null +++ b/backend/app/views/public/tournaments/show.html.erb @@ -0,0 +1,73 @@ +<% content_for :title, t("tournaments.hub.title", name: @tournament.name) %> +<% content_for :robots, "noindex, nofollow" %> + +
+ + +
+
+ <% logo = @tournament.effective_logo_url %> + +
+

<%= @tournament.name %>

+

+ <%= @tournament.sport_label %> + · <%= l(@tournament.starts_on) %> – <%= l(@tournament.ends_on) %> + · <%= t("tournaments.status.#{@tournament.status}") %> + · <%= t("tournaments.form.formats.#{@tournament.format_kind}") %> +

+ <% if @writable %> +

<%= t("tournaments.hub.tournament_logo_hint") %>

+ <% end %> +
+
+
+ <% if @tournament.published? %> + <%= link_to t("tournaments.hub.public_page"), public_tournament_page_path(@tournament.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %> + <% if @writable %> + <%= button_to t("tournaments.hub.unpublish"), public_unpublish_tournament_path(@tournament), method: :post, class: "btn btn-secondary" %> + <% end %> + <% elsif @writable %> + <%= button_to t("tournaments.hub.publish"), public_publish_tournament_path(@tournament), method: :post, class: "btn btn-primary" %> + <% end %> + <% if @writable && !@tournament.archived? %> + <%= button_to t("tournaments.hub.archive"), public_archive_tournament_path(@tournament), method: :post, class: "btn btn-secondary", form: { data: { turbo_confirm: t("tournaments.hub.archive") } } %> + <% end %> + <%= button_to t("tournaments.hub.delete"), public_tournament_path(@tournament), method: :delete, class: "btn btn-secondary", + form: { data: { turbo_confirm: t("tournaments.hub.delete_confirm", name: @tournament.name), confirm_kind: "delete" } } %> +
+
+ + <% @overlap_warnings.each do |warning| %> +
<%= warning %>
+ <% end %> + + + + <%= render "public/tournaments/tab_#{@tab}" %> +
diff --git a/backend/app/views/shared/_marketing_footer.html.erb b/backend/app/views/shared/_marketing_footer.html.erb index 94f76d8..0cd0053 100644 --- a/backend/app/views/shared/_marketing_footer.html.erb +++ b/backend/app/views/shared/_marketing_footer.html.erb @@ -8,6 +8,7 @@ <%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.pricing"), public_prezzi_path %> · <%= link_to t("footer.live"), public_live_index_path %> · + <%= link_to t("nav.tournaments"), public_tournament_pages_path %> · <%= link_to t("common.faq"), public_faq_path %> · <%= link_to t("common.privacy"), public_privacy_path %> · <%= link_to t("common.cookies"), public_cookies_path %> · diff --git a/backend/app/views/shared/_marketing_nav.html.erb b/backend/app/views/shared/_marketing_nav.html.erb index 3ad5ecb..0fb7716 100644 --- a/backend/app/views/shared/_marketing_nav.html.erb +++ b/backend/app/views/shared/_marketing_nav.html.erb @@ -34,6 +34,7 @@ <%= link_to t("nav.faq"), public_faq_path, class: (request.path == "/faq" ? "nav-active" : nil) %> <%= link_to t("nav.live"), public_live_index_path, class: (live_section ? "nav-active" : nil) %> <%= link_to t("nav.teams"), public_team_pages_path, class: (request.path.start_with?("/squadre") ? "nav-active" : nil) %> + <%= link_to t("nav.tournaments"), public_tournament_pages_path, class: (request.path.start_with?("/tornei") ? "nav-active" : nil) %>