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) %>
+
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 %>
+
+<% 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 @@
+
+<% 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? } %>
+
+
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) %>
+
+
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? %>
+
+
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) %>
+
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 @@
+
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) %>
+
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 @@
+
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 @@
+
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 @@
+
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" %>
+
+
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" %>
+
+
+
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" %>
+
+
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) %>
<% if logged_in? %>
<% if current_user.primary_club || current_user.manageable_teams.any? %>
diff --git a/backend/app/views/shared/_plan_cards.html.erb b/backend/app/views/shared/_plan_cards.html.erb
index fa668b9..2b36cec 100644
--- a/backend/app/views/shared/_plan_cards.html.erb
+++ b/backend/app/views/shared/_plan_cards.html.erb
@@ -67,6 +67,7 @@
) %>
<% if plan.slug == "premium_full" %>
<%= raw t("pages.plans.cover_sponsor_html") %>
+
<%= raw t("pages.plans.tournaments_html") %>
<% end %>
<% if plan.slug == "premium_full" %>
diff --git a/backend/app/views/tournaments/invitation_mailer/transmission_invite.html.erb b/backend/app/views/tournaments/invitation_mailer/transmission_invite.html.erb
new file mode 100644
index 0000000..b226d3f
--- /dev/null
+++ b/backend/app/views/tournaments/invitation_mailer/transmission_invite.html.erb
@@ -0,0 +1,32 @@
+
+ <% if @body_html.present? %>
+ <%= raw @body_html %>
+ <% else %>
+
<%= t("mailers.tournament_invite.hello") %>
+
+ <%= raw t(
+ "mailers.tournament_invite.body_html",
+ inviter: @invited_by.name,
+ tournament: @tournament.name,
+ club: @club.name,
+ assignment: @invitation.scope_label
+ ) %>
+
+ <% if @invitation.note.present? %>
+
<%= simple_format(@invitation.note) %>
+ <% end %>
+
+
+ <%= t("mailers.tournament_invite.cta") %>
+
+
+
+ <%= t("mailers.tournament_invite.link_fallback") %>
+ <%= @invite_url %>
+
+
<%= t("mailers.tournament_invite.steps") %>
+
<%= t("mailers.tournament_invite.expiry", date: @expires_on) %>
+
<%= t("mailers.tournament_invite.ignore") %>
+ <% end %>
+
<%= t("mailers.tournament_invite.footer", email: MatchLiveTv.privacy_controller_email) %>
+
diff --git a/backend/app/views/tournaments/invitation_mailer/transmission_invite.text.erb b/backend/app/views/tournaments/invitation_mailer/transmission_invite.text.erb
new file mode 100644
index 0000000..b0175e2
--- /dev/null
+++ b/backend/app/views/tournaments/invitation_mailer/transmission_invite.text.erb
@@ -0,0 +1,26 @@
+<% if @body_text.present? %>
+<%= @body_text %>
+<% else %>
+<%= t("mailers.tournament_invite.hello") %>
+
+<%= t(
+ "mailers.tournament_invite.body_text",
+ inviter: @invited_by.name,
+ tournament: @tournament.name,
+ club: @club.name,
+ assignment: @invitation.scope_label
+) %>
+<% if @invitation.note.present? %>
+
+<%= @invitation.note %>
+<% end %>
+
+<%= t("mailers.tournament_invite.cta") %>:
+<%= @invite_url %>
+
+<%= t("mailers.tournament_invite.steps") %>
+<%= t("mailers.tournament_invite.expiry", date: @expires_on) %>
+<%= t("mailers.tournament_invite.ignore") %>
+<% end %>
+
+<%= t("mailers.tournament_invite.footer", email: MatchLiveTv.privacy_controller_email) %>
diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml
index aa4e6a3..b15115d 100644
--- a/backend/config/locales/app.en.yml
+++ b/backend/config/locales/app.en.yml
@@ -57,6 +57,13 @@ en:
edit_club: Edit club
subscription: Subscription
new_team: New team
+ tournaments: Tournaments
+ tournaments_full_badge: FULL
+ tournaments_locked_hint: Tournaments are available on Premium Full only.
+ new_tournament: New tournament
+ tournaments_heading: Tournaments
+ tournament_dates: Dates
+ tournament_status: Status
live_streams: Live streams
replay_archive: Replay archive
replay_heading: Replay
@@ -691,6 +698,7 @@ en:
invalid: Invalid invitation
wrong_email: "This invitation is for %{email}"
joined_team: You've joined the team!
+ joined_tournament: You're assigned to broadcast this tournament. Open the Match Live TV app to go live.
signup_to_accept: "Sign up with %{email} to accept the invitation"
registrations:
accept_terms_required: You must accept the privacy policy and terms of service to register.
diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml
index 961f633..bc855ad 100644
--- a/backend/config/locales/app.it.yml
+++ b/backend/config/locales/app.it.yml
@@ -57,6 +57,13 @@ it:
edit_club: Modifica società
subscription: Abbonamento
new_team: Nuova squadra
+ tournaments: Tornei
+ tournaments_full_badge: FULL
+ tournaments_locked_hint: I tornei sono disponibili solo con Premium Full.
+ new_tournament: Nuovo torneo
+ tournaments_heading: Tornei
+ tournament_dates: Date
+ tournament_status: Stato
live_streams: Dirette live
replay_archive: Archivio Replay
replay_heading: Replay
@@ -714,6 +721,7 @@ it:
invalid: Invito non valido
wrong_email: "Questo invito è per %{email}"
joined_team: Sei entrato nella squadra!
+ joined_tournament: Sei incaricato delle dirette del torneo. Apri l’app Match Live TV per andare in onda.
signup_to_accept: "Registrati con %{email} per accettare l'invito"
registrations:
accept_terms_required: Devi accettare l'informativa privacy e i termini di servizio per registrarti.
diff --git a/backend/config/locales/mailers.en.yml b/backend/config/locales/mailers.en.yml
index c12603a..ca089b9 100644
--- a/backend/config/locales/mailers.en.yml
+++ b/backend/config/locales/mailers.en.yml
@@ -61,6 +61,17 @@ en:
expiry: "The link is valid until %{date}."
ignore: If you were not expecting this message, you can ignore it.
footer: "Match Live TV — %{email}"
+ tournament_invite:
+ subject: "Broadcast invite — %{tournament} (%{club})"
+ hello: Hi,
+ body_html: "
%{inviter} invited you to broadcast matches at
%{tournament} (%{club}) — %{assignment}."
+ body_text: "%{inviter} invited you to broadcast matches at %{tournament} (%{club}) — %{assignment}."
+ cta: Accept the invite
+ link_fallback: "If the button does not work, open this link:"
+ steps: "Sign in or register with this same email, accept the invite, then start streams from the Match Live TV app."
+ expiry: "The link is valid until %{date}."
+ ignore: If you were not expecting this message, you can ignore it.
+ footer: "Match Live TV — %{email}"
invoice:
subject: "Invoice %{number} — Match Live TV"
attachment_prefix: invoice
diff --git a/backend/config/locales/mailers.it.yml b/backend/config/locales/mailers.it.yml
index ee4c567..04bf658 100644
--- a/backend/config/locales/mailers.it.yml
+++ b/backend/config/locales/mailers.it.yml
@@ -61,6 +61,17 @@ it:
expiry: "Il link è valido fino al %{date}."
ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo.
footer: "Match Live TV — %{email}"
+ tournament_invite:
+ subject: "Invito a trasmettere — %{tournament} (%{club})"
+ hello: Ciao,
+ body_html: "
%{inviter} ti ha invitato a trasmettere partite del torneo
%{tournament} (%{club}) — %{assignment}."
+ body_text: "%{inviter} ti ha invitato a trasmettere partite del torneo %{tournament} (%{club}) — %{assignment}."
+ cta: Accetta l’invito
+ link_fallback: "Se il pulsante non funziona, apri questo link:"
+ steps: "Accedi o registrati con questa stessa email, accetta l’invito e avvia le dirette dall’app Match Live TV."
+ expiry: "Il link è valido fino al %{date}."
+ ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo.
+ footer: "Match Live TV — %{email}"
invoice:
subject: "Fattura %{number} — Match Live TV"
attachment_prefix: fattura
diff --git a/backend/config/locales/pages.de.yml b/backend/config/locales/pages.de.yml
index c91a56a..675ff15 100644
--- a/backend/config/locales/pages.de.yml
+++ b/backend/config/locales/pages.de.yml
@@ -133,6 +133,7 @@ de:
youtube_club: Vereinskanal
youtube_none: nein
cover_sponsor_html: "Anpassbares Titelbild
mit Sponsoren"
+ tournaments_html: "Mehrtägige Turniere, Delegierung der Übertragungen und Tableau"
complete_billing: Rechnungsdaten vervollständigen
start_free: Kostenlos starten
register_with_price: "Registrieren — %{price}"
diff --git a/backend/config/locales/pages.en.yml b/backend/config/locales/pages.en.yml
index 159a179..69dd0ee 100644
--- a/backend/config/locales/pages.en.yml
+++ b/backend/config/locales/pages.en.yml
@@ -133,6 +133,7 @@ en:
youtube_club: club channel
youtube_none: "no"
cover_sponsor_html: "Customisable cover
with sponsors"
+ tournaments_html: "Multi-day tournaments, delegated broadcasts and bracket"
complete_billing: Complete billing details
start_free: Start for free
register_with_price: "Register — %{price}"
diff --git a/backend/config/locales/pages.es.yml b/backend/config/locales/pages.es.yml
index 1d4a80f..9605fa5 100644
--- a/backend/config/locales/pages.es.yml
+++ b/backend/config/locales/pages.es.yml
@@ -133,6 +133,7 @@ es:
youtube_club: canal del club
youtube_none: "no"
cover_sponsor_html: "Portada personalizable
con patrocinadores"
+ tournaments_html: "Torneos de varios días, delegación de directos y cuadro"
complete_billing: Completar datos de facturación
start_free: Empieza gratis
register_with_price: "Regístrate — %{price}"
diff --git a/backend/config/locales/pages.fr.yml b/backend/config/locales/pages.fr.yml
index fb9d603..fbe6c47 100644
--- a/backend/config/locales/pages.fr.yml
+++ b/backend/config/locales/pages.fr.yml
@@ -133,6 +133,7 @@ fr:
youtube_club: chaîne du club
youtube_none: non
cover_sponsor_html: "Jaquette personnalisable
avec sponsors"
+ tournaments_html: "Tournois multi-jours, délégation des directs et tableau"
complete_billing: Compléter les données de facturation
start_free: Commencer gratuitement
register_with_price: "S'inscrire — %{price}"
diff --git a/backend/config/locales/pages.it.yml b/backend/config/locales/pages.it.yml
index ece9dc5..f09c0ac 100644
--- a/backend/config/locales/pages.it.yml
+++ b/backend/config/locales/pages.it.yml
@@ -133,6 +133,7 @@ it:
youtube_club: canale società
youtube_none: "no"
cover_sponsor_html: "Copertina personalizzabile
con sponsor"
+ tournaments_html: "Tornei multi-giorno, delega dirette e tabellone"
complete_billing: Completa dati di fatturazione
start_free: Inizia gratis
register_with_price: "Registrati — %{price}"
diff --git a/backend/config/locales/tournaments.en.yml b/backend/config/locales/tournaments.en.yml
new file mode 100644
index 0000000..a748c3a
--- /dev/null
+++ b/backend/config/locales/tournaments.en.yml
@@ -0,0 +1,245 @@
+en:
+ tournaments:
+ tbd: TBD
+ index:
+ title: "Tournaments — %{name}"
+ heading: Tournaments
+ lead: Organize a multi-day tournament, delegate broadcasts, and publish groups, standings and a bracket.
+ new: New tournament
+ empty: No tournaments yet.
+ open: Manage
+ delete: Delete
+ col_name: Tournament
+ col_dates: Dates
+ col_status: Status
+ upgrade: Tournaments are available on Premium Full.
+ new:
+ title: "New tournament — %{name}"
+ heading: New tournament
+ submit: Create tournament
+ form:
+ name: Name
+ sport: Sport
+ venue: Venue
+ starts_on: Start
+ ends_on: End
+ format: Format
+ courts: Courts (one per line)
+ knockout_size: Knockout size
+ description: Description
+ logo: Tournament logo
+ logo_hint: PNG, JPEG or WebP. Shown on the public page and in the tournament list.
+ formats:
+ groups: Groups only
+ knockout: Knockout only
+ mixed: Groups + knockout
+ free: Open schedule
+ status:
+ draft: Draft
+ published: Published
+ live: Live
+ archived: Archived
+ hub:
+ title: "%{name} — Tournament"
+ back: "← Club"
+ public_page: Public page
+ publish: Publish
+ unpublish: Back to draft
+ archive: Archive
+ delete: Delete
+ delete_confirm: "Delete tournament “%{name}”? Teams, schedule, invites and related replays will be removed. This cannot be undone."
+ tabs:
+ squadre: Teams
+ struttura: Structure
+ calendario: Schedule
+ dirette: Streaming
+ tabellone: Bracket
+ overlap_warning: "Slot %{slot} has %{count} matches (plan limit: %{limit} concurrent streams)."
+ add_participant: Add team
+ participant_name: Team name
+ participant_group: Group
+ no_group: No group
+ link_club_team: Link a club team (optional)
+ add_group: Add group
+ generate_group_matches: Generate group matches
+ generate_hint: Builds the round-robin, then you can change times and courts on the schedule.
+ calendar_edit_hint: Edit date, time, court and teams on each row, then Save. Semis and the final can be set here or on the Bracket tab.
+ knockout_how_title: Semis and final
+ knockout_how_groups: Enter group results first. Standings decide 1st and 2nd.
+ knockout_how_seed: Use “Fill bracket” to put 1sts and 2nds into the semis, or pick the teams yourself.
+ knockout_how_advance: When you save a semi result, the final fills with the winners automatically.
+ knockout_round_empty: No matches in this round yet. Fill the bracket, or add a match from the schedule and pick this round.
+ winner_of: "Winner — %{match}"
+ winner_tbd: Winner to be decided
+ group_rank: "%{rank} %{group}"
+ group_rank_tbd: Qualified from group
+ schedule_another: Add another match
+ save_match: Save
+ logo: Logo
+ logo_file: Upload logo
+ logo_hint: Upload team logos here (PNG, JPEG or WebP). They appear on the schedule, overlay and public page.
+ tournament_logo_hint: Shown on the public page and in the tournament list.
+ squadre_edit_hint: On each row you can change name, group and logo. Upload a logo or edit the name, then press Save.
+ schedule_match: Schedule match
+ home: Home
+ away: Away
+ swap_sides: Swap home and away
+ streaming: Streaming
+ streaming_pending: pending
+ streaming_unassigned: Uncovered
+ streaming_invite: Invite
+ streaming_revoke: Revoke
+ streaming_revoke_confirm: "Revoke %{name} from this match?"
+ streaming_cancel: "Cancel the invite to %{email}?"
+ streaming_cancel_multi: "Cancel the invite to %{email}? It covers %{count} matches."
+ court: Court
+ datetime: Date and time
+ group: Group
+ round: Round
+ no_round: —
+ invite_email: Operator email
+ stream_plan_title: Broadcast plan
+ stream_plan_hint: Who is streaming, and where each broadcast stands. Tick uncovered matches and invite an operator below.
+ stream_plan_invite_title: Invite an operator
+ stream_plan_invite_hint: Tick matches in the plan, or cover a whole court for a day. The email text is already below, fully editable.
+ stream_plan_live_col: Status
+ stream_plan_live:
+ live: Live
+ waiting_time: Waiting for kickoff
+ waiting_operator: Waiting for operator
+ ended: Ended
+ uncovered: Uncovered
+ stream_plan_live_count:
+ live:
+ one: "%{count} live"
+ other: "%{count} live"
+ waiting_time:
+ one: "%{count} waiting for kickoff"
+ other: "%{count} waiting for kickoff"
+ waiting_operator:
+ one: "%{count} waiting for operator"
+ other: "%{count} waiting for operator"
+ ended:
+ one: "%{count} ended"
+ other: "%{count} ended"
+ uncovered:
+ one: "%{count} uncovered"
+ other: "%{count} uncovered"
+ stream_plan_assigned:
+ one: "%{count} covered"
+ other: "%{count} covered"
+ stream_plan_pending:
+ one: "%{count} pending"
+ other: "%{count} pending"
+ stream_plan_open:
+ one: "%{count} uncovered"
+ other: "%{count} uncovered"
+ stream_plan_select_open: Select uncovered
+ stream_plan_select_match: Select this match
+ stream_plan_unscheduled: Unscheduled
+ stream_plan_match: Match
+ stream_plan_operator: Operator
+ stream_plan_invite_selected: Invite selected
+ stream_plan_court_day: Court and day
+ stream_plan_court_day_hint: If no matches are ticked, the invite covers every match on this court that day.
+ stream_plan_invite_court: Invite court and day
+ stream_plan_customize: Customize email
+ stream_plan_need_selection: Select at least one match, or invite a court and day.
+ invite_note: Note
+ invite_note_placeholder: E.g. Bring the tripod, arrive at 8:30
+ invite_match: This match
+ invite_court_day: Court and day
+ invite_submit: Send invite
+ invite_save: Save draft
+ invite_email_body: Email text
+ invite_email_hint: Pre-filled template, fully editable. Save a draft to come back later; when sending, {{link_invito}} becomes the real invite link. Drop an image into the editor or use the button; click the image and drag the bottom-right corner to resize.
+ invite_draft_hint: "Draft saved on %{time}. It stays here until you overwrite it."
+ invite_email_scope_generic: the matches assigned in this invite
+ invite_editor_toolbar: Formatting
+ invite_editor_bold: Bold
+ invite_editor_italic: Italic
+ invite_editor_underline: Underline
+ invite_editor_ul: Bulleted list
+ invite_editor_ol: Numbered list
+ invite_editor_link: Insert link
+ invite_editor_link_prompt: Link URL
+ invite_editor_image: Insert image
+ invite_image_invalid: Use a PNG, JPEG, WebP or GIF file.
+ invite_image_too_big: Image too large (max 2 MB).
+ invite_image_uploading: Uploading image…
+ invite_image_failed: Upload failed.
+ result: Result
+ save_result: Save
+ propose_knockout: Fill bracket
+ propose_hint: Fills the first knockout round with group 1sts/2nds. You can edit afterwards.
+ standings: Standings
+ played: P
+ won: W
+ lost: L
+ points: Pts
+ no_matches: No matches scheduled.
+ page:
+ title: "%{name} — %{club}"
+ draft_banner: Draft preview — this page is not public yet.
+ back_to_list: "← All tournaments"
+ tabs_label: Tournament sections
+ tabs:
+ risultati: Results
+ tabellone: Bracket
+ section_live: Live now
+ section_upcoming: Upcoming
+ section_board: Match board
+ board_hint: Every match in the tournament, with a live or replay link when available.
+ section_groups: Groups and standings
+ section_bracket: Bracket
+ bracket_empty: The bracket will appear once standings or knockout rounds are ready.
+ section_replays: Replays
+ watch: Watch
+ watch_live: Watch live
+ watch_replay: Watch replay
+ score: Score
+ no_media: —
+ state:
+ live: Live
+ replay: Replay
+ scheduled: Scheduled
+ ended: Finished
+ waiting: Waiting
+ directory:
+ meta_title: Tournaments — Match Live TV
+ meta_description: Follow groups, brackets, live streams and replays of tournaments published on Match Live TV.
+ heading: Tournaments
+ hint: Boards, standings and links to live streams or replays. Open a tournament to follow it.
+ empty: No public tournaments right now.
+ follow: Follow the board →
+ section_on_live: Tournaments
+ live_hint: Groups, bracket and live streams on a page you can share.
+ all_link: All tournaments
+ flash:
+ tournaments:
+ created: Tournament created. Add teams and the schedule.
+ updated: Tournament updated.
+ published: Public page is live.
+ unpublished: Tournament moved back to draft.
+ archived: Tournament archived.
+ deleted: Tournament deleted.
+ delete_blocked_live: End live streams before deleting this tournament.
+ archived_locked: This tournament is archived.
+ group_matches_created: "Created %{count} group matches."
+ knockout_proposed: "Bracket updated (%{count} matches)."
+ participant_added: Team registered.
+ participant_updated: Team updated.
+ participant_removed: Team removed.
+ group_added: Group added.
+ group_removed: Group removed.
+ match_scheduled: Match added to the schedule.
+ match_updated: Match updated.
+ sides_swapped: Home and away swapped.
+ sides_swap_failed: Could not swap home and away.
+ match_deleted: Match deleted.
+ invite_email_sent: "Invite sent to %{email} (link valid 7 days)"
+ invite_email_failed: "Invite created, but the email to %{email} could not be sent. Copy and share the link."
+ invite_draft_saved: Email draft saved. You can send it later.
+ invite_canceled: Invite canceled.
+ assignment_revoked: Operator removed from this match.
+ result_saved: Result saved.
diff --git a/backend/config/locales/tournaments.it.yml b/backend/config/locales/tournaments.it.yml
new file mode 100644
index 0000000..4a81b2c
--- /dev/null
+++ b/backend/config/locales/tournaments.it.yml
@@ -0,0 +1,245 @@
+it:
+ tournaments:
+ tbd: Da definire
+ index:
+ title: "Tornei — %{name}"
+ heading: Tornei
+ lead: Organizza un torneo multi-giorno, delega le dirette e pubblica gironi, classifiche e tabellone.
+ new: Nuovo torneo
+ empty: Nessun torneo.
+ open: Gestisci
+ delete: Elimina
+ col_name: Torneo
+ col_dates: Date
+ col_status: Stato
+ upgrade: I tornei sono disponibili con Premium Full.
+ new:
+ title: "Nuovo torneo — %{name}"
+ heading: Nuovo torneo
+ submit: Crea torneo
+ form:
+ name: Nome
+ sport: Sport
+ venue: Sede
+ starts_on: Inizio
+ ends_on: Fine
+ format: Formula
+ courts: Campi (uno per riga)
+ knockout_size: Squadre in tabellone
+ description: Descrizione
+ logo: Logo del torneo
+ logo_hint: PNG, JPEG o WebP. Compare sulla pagina pubblica e nell'elenco tornei.
+ formats:
+ groups: Solo gironi
+ knockout: Solo eliminazione
+ mixed: Gironi + tabellone
+ free: Calendario libero
+ status:
+ draft: Bozza
+ published: Pubblicato
+ live: In corso
+ archived: Archiviato
+ hub:
+ title: "%{name} — Torneo"
+ back: "← Società"
+ public_page: Pagina pubblica
+ publish: Pubblica
+ unpublish: Torna in bozza
+ archive: Archivia
+ delete: Elimina
+ delete_confirm: "Eliminare il torneo «%{name}»? Squadre, calendario, inviti e replay associati verranno cancellati. L’operazione non è reversibile."
+ tabs:
+ squadre: Squadre
+ struttura: Struttura
+ calendario: Calendario
+ dirette: Dirette
+ tabellone: Tabellone
+ overlap_warning: "Nella fascia %{slot} hai %{count} gare (limite piano: %{limit} dirette)."
+ add_participant: Aggiungi squadra
+ participant_name: Nome squadra
+ participant_group: Girone
+ no_group: Nessun girone
+ link_club_team: Collega una squadra della società (facoltativo)
+ add_group: Aggiungi girone
+ generate_group_matches: Genera partite dei gironi
+ generate_hint: Crea il round-robin sui gironi, poi cambia orari e campi dal calendario.
+ calendar_edit_hint: Su ogni riga puoi cambiare data, ora, campo e squadre, poi Salva. Semifinali e finali si scelgono qui o dal tab Tabellone.
+ knockout_how_title: Semifinali e finali
+ knockout_how_groups: Chiudi i risultati dei gironi. La classifica decide chi è 1° e 2°.
+ knockout_how_seed: Usa «Compila tabellone» per mettere 1° e 2° in semifinale, oppure scegli tu le squadre dai menu.
+ knockout_how_advance: Quando salvi il risultato di una semifinale, la finale si riempie da sola con le vincenti.
+ knockout_round_empty: Nessuna gara in questa fase. Compila il tabellone oppure aggiungila dal calendario scegliendo la fase.
+ winner_of: "Vincente — %{match}"
+ winner_tbd: Vincente da definire
+ group_rank: "%{rank}° %{group}"
+ group_rank_tbd: Qualificata da girone
+ schedule_another: Aggiungi un'altra partita
+ save_match: Salva
+ logo: Logo
+ logo_file: Carica logo
+ logo_hint: I loghi delle squadre si caricano qui (PNG, JPEG o WebP). Compariranno in calendario, overlay e pagina pubblica.
+ tournament_logo_hint: Compare sulla pagina pubblica e nell'elenco tornei.
+ squadre_edit_hint: Su ogni riga puoi cambiare nome, girone e logo. Carica logo oppure modifica il nome e premi Salva.
+ schedule_match: Programma partita
+ home: Casa
+ away: Ospite
+ swap_sides: Inverti casa e ospite
+ streaming: Streaming
+ streaming_pending: in attesa
+ streaming_unassigned: Da coprire
+ streaming_invite: Invita
+ streaming_revoke: Revoca
+ streaming_revoke_confirm: "Revocare %{name} da questa partita?"
+ streaming_cancel: "Annullare l'invito a %{email}?"
+ streaming_cancel_multi: "Annullare l'invito a %{email}? Vale per %{count} partite."
+ court: Campo
+ datetime: Data e ora
+ group: Girone
+ round: Fase
+ no_round: —
+ invite_email: Email operatore
+ stream_plan_title: Piano dirette
+ stream_plan_hint: Chi trasmette, e a che punto è ogni diretta. Spunta le gare da coprire e invita un operatore più sotto.
+ stream_plan_invite_title: Invita un operatore
+ stream_plan_invite_hint: Spunta le partite nel piano, oppure copri un campo intero per giornata. Il testo della mail è già qui sotto, modificabile.
+ stream_plan_live_col: Stato
+ stream_plan_live:
+ live: In diretta
+ waiting_time: "In attesa dell'orario"
+ waiting_operator: "In attesa dell'operatore"
+ ended: Terminata
+ uncovered: Da coprire
+ stream_plan_live_count:
+ live:
+ one: "%{count} in diretta"
+ other: "%{count} in diretta"
+ waiting_time:
+ one: "%{count} in attesa dell'orario"
+ other: "%{count} in attesa dell'orario"
+ waiting_operator:
+ one: "%{count} in attesa dell'operatore"
+ other: "%{count} in attesa dell'operatore"
+ ended:
+ one: "%{count} terminata"
+ other: "%{count} terminate"
+ uncovered:
+ one: "%{count} da coprire"
+ other: "%{count} da coprire"
+ stream_plan_assigned:
+ one: "%{count} coperta"
+ other: "%{count} coperte"
+ stream_plan_pending:
+ one: "%{count} in attesa"
+ other: "%{count} in attesa"
+ stream_plan_open:
+ one: "%{count} da coprire"
+ other: "%{count} da coprire"
+ stream_plan_select_open: Seleziona da coprire
+ stream_plan_select_match: Seleziona questa partita
+ stream_plan_unscheduled: Da programmare
+ stream_plan_match: Partita
+ stream_plan_operator: Operatore
+ stream_plan_invite_selected: Invita selezionate
+ stream_plan_court_day: Campo e giornata
+ stream_plan_court_day_hint: Se non spunti partite, l’invito copre tutte le gare di questo campo in quella data.
+ stream_plan_invite_court: Invita campo e giornata
+ stream_plan_customize: Personalizza la mail
+ stream_plan_need_selection: Seleziona almeno una partita, oppure invita un campo e una giornata.
+ invite_note: Nota
+ invite_note_placeholder: Es. Porta il treppiede, arrivo alle 8:30
+ invite_match: Questa partita
+ invite_court_day: Campo e giornata
+ invite_submit: Invia invito
+ invite_save: Salva bozza
+ invite_email_body: Testo della mail
+ invite_email_hint: Modello già compilato, modificabile. Salva la bozza per riprenderla dopo; all’invio {{link_invito}} diventa il link reale. Trascina un’immagine nell’editor o usa il pulsante; per ridimensionarla cliccala e tira l’angolo in basso a destra.
+ invite_draft_hint: "Bozza salvata il %{time}. Resta qui finché non la sovrascrivi."
+ invite_email_scope_generic: le partite assegnate in questo invito
+ invite_editor_toolbar: Formattazione
+ invite_editor_bold: Grassetto
+ invite_editor_italic: Corsivo
+ invite_editor_underline: Sottolineato
+ invite_editor_ul: Elenco puntato
+ invite_editor_ol: Elenco numerato
+ invite_editor_link: Inserisci link
+ invite_editor_link_prompt: URL del link
+ invite_editor_image: Inserisci immagine
+ invite_image_invalid: Usa un file PNG, JPEG, WebP o GIF.
+ invite_image_too_big: Immagine troppo grande (max 2 MB).
+ invite_image_uploading: Caricamento immagine…
+ invite_image_failed: Caricamento non riuscito.
+ result: Risultato
+ save_result: Salva
+ propose_knockout: Compila tabellone
+ propose_hint: Compila la prima fase con 1°/2° dei gironi. Puoi spostare a mano.
+ standings: Classifica
+ played: G
+ won: V
+ lost: P
+ points: Pt
+ no_matches: Nessuna partita in calendario.
+ page:
+ title: "%{name} — %{club}"
+ draft_banner: Anteprima bozza — la pagina non è ancora pubblica.
+ back_to_list: "← Tutti i tornei"
+ tabs_label: Sezioni del torneo
+ tabs:
+ risultati: Risultati
+ tabellone: Tabellone
+ section_live: In onda ora
+ section_upcoming: Prossime gare
+ section_board: Cartellone
+ board_hint: Tutte le gare del torneo, con diretta o replay quando disponibili.
+ section_groups: Gironi e classifiche
+ section_bracket: Tabellone
+ bracket_empty: Il tabellone sarà visibile quando ci sono classifiche o fasi a eliminazione.
+ section_replays: Replay
+ watch: Guarda
+ watch_live: Guarda diretta
+ watch_replay: Guarda replay
+ score: Risultato
+ no_media: —
+ state:
+ live: In diretta
+ replay: Replay
+ scheduled: In programma
+ ended: Terminata
+ waiting: In attesa
+ directory:
+ meta_title: Tornei in corso — Match Live TV
+ meta_description: Segui gironi, tabellone, dirette e replay dei tornei pubblicati su Match Live TV.
+ heading: Tornei
+ hint: Cartellone, classifiche e link alle dirette o ai replay. Scegli un torneo per seguire lo svolgimento.
+ empty: Nessun torneo pubblico al momento.
+ follow: Segui il cartellone →
+ section_on_live: Tornei
+ live_hint: Gironi, tabellone e dirette del torneo, in una pagina da condividere.
+ all_link: Tutti i tornei
+ flash:
+ tournaments:
+ created: Torneo creato. Aggiungi le squadre e il calendario.
+ updated: Torneo aggiornato.
+ published: Pagina pubblica attiva.
+ unpublished: Torneo riportato in bozza.
+ archived: Torneo archiviato.
+ deleted: Torneo eliminato.
+ delete_blocked_live: Chiudi le dirette in corso prima di eliminare il torneo.
+ archived_locked: Il torneo è archiviato.
+ group_matches_created: "Create %{count} partite dai gironi."
+ knockout_proposed: "Tabellone aggiornato (%{count} gare)."
+ participant_added: Squadra iscritta.
+ participant_updated: Squadra aggiornata.
+ participant_removed: Squadra rimossa.
+ group_added: Girone aggiunto.
+ group_removed: Girone rimosso.
+ match_scheduled: Partita inserita in calendario.
+ match_updated: Partita aggiornata.
+ sides_swapped: Casa e ospite invertiti.
+ sides_swap_failed: Impossibile invertire casa e ospite.
+ match_deleted: Partita eliminata.
+ invite_email_sent: "Invito inviato a %{email} (link valido 7 giorni)"
+ invite_email_failed: "Invito creato, ma l’email a %{email} non è partita. Copia e condividi il link."
+ invite_draft_saved: Bozza della mail salvata. Puoi inviarla quando vuoi.
+ invite_canceled: Invito annullato.
+ assignment_revoked: Operatore rimosso da questa partita.
+ result_saved: Risultato salvato.
diff --git a/backend/config/locales/ui.de.yml b/backend/config/locales/ui.de.yml
index d85a15b..c51e567 100644
--- a/backend/config/locales/ui.de.yml
+++ b/backend/config/locales/ui.de.yml
@@ -12,6 +12,7 @@ de:
faq: FAQ
live: Live-Spiele
teams: Teams
+ tournaments: Turniere
my_club: Mein Verein
my_team: Mein Team
login: Anmelden
diff --git a/backend/config/locales/ui.en.yml b/backend/config/locales/ui.en.yml
index e9edffd..3108ad4 100644
--- a/backend/config/locales/ui.en.yml
+++ b/backend/config/locales/ui.en.yml
@@ -12,6 +12,7 @@ en:
faq: FAQ
live: Live matches
teams: Teams
+ tournaments: Tournaments
my_club: My club
my_team: My team
login: Log in
diff --git a/backend/config/locales/ui.es.yml b/backend/config/locales/ui.es.yml
index 4c03902..e49ff5d 100644
--- a/backend/config/locales/ui.es.yml
+++ b/backend/config/locales/ui.es.yml
@@ -12,6 +12,7 @@ es:
faq: FAQ
live: Directos
teams: Equipos
+ tournaments: Torneos
my_club: Mi club
my_team: Mi equipo
login: Acceder
diff --git a/backend/config/locales/ui.fr.yml b/backend/config/locales/ui.fr.yml
index 852e441..ff03373 100644
--- a/backend/config/locales/ui.fr.yml
+++ b/backend/config/locales/ui.fr.yml
@@ -12,6 +12,7 @@ fr:
faq: FAQ
live: Directs
teams: Équipes
+ tournaments: Tournois
my_club: Mon club
my_team: Mon équipe
login: Connexion
diff --git a/backend/config/locales/ui.it.yml b/backend/config/locales/ui.it.yml
index d999153..ce14887 100644
--- a/backend/config/locales/ui.it.yml
+++ b/backend/config/locales/ui.it.yml
@@ -12,6 +12,7 @@ it:
faq: FAQ
live: Dirette live
teams: Squadre
+ tournaments: Tornei
my_club: La mia società
my_team: La mia squadra
login: Accedi
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index 0153d12..7641089 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -158,6 +158,9 @@ Rails.application.routes.draw do
get "squadre", to: "public/team_pages#index", as: :public_team_pages
get "squadre/:slug", to: "public/team_pages#show", as: :public_team_page,
constraints: { slug: /[a-z0-9]+(?:-[a-z0-9]+)*/ }
+ get "tornei", to: "public/tournament_pages#index", as: :public_tournament_pages
+ get "tornei/:slug", to: "public/tournament_pages#show", as: :public_tournament_page,
+ constraints: { slug: /[a-z0-9]+(?:-[a-z0-9]+)*/ }
get "replay", to: "public/replay#index", as: :public_replay_index
get "replay/:id", to: "public/replay#show", as: :public_replay
get "replay/:id/stream", to: "public/replay#stream", as: :public_replay_stream
@@ -228,6 +231,32 @@ Rails.application.routes.draw do
get "teams/new", to: redirect("/clubs/new")
get "clubs/:club_id/teams/new", to: "teams#new", as: :new_club_team
post "clubs/:club_id/teams", to: "teams#create", as: :club_teams
+ get "clubs/:club_id/tournaments", to: "tournaments#index", as: :club_tournaments
+ get "clubs/:club_id/tournaments/new", to: "tournaments#new", as: :new_club_tournament
+ post "clubs/:club_id/tournaments", to: "tournaments#create"
+ get "tournaments/:id", to: "tournaments#show", as: :tournament
+ patch "tournaments/:id", to: "tournaments#update"
+ delete "tournaments/:id", to: "tournaments#destroy"
+ post "tournaments/:id/publish", to: "tournaments#publish", as: :publish_tournament
+ post "tournaments/:id/unpublish", to: "tournaments#unpublish", as: :unpublish_tournament
+ post "tournaments/:id/archive", to: "tournaments#archive", as: :archive_tournament
+ post "tournaments/:id/generate_group_matches", to: "tournaments#generate_group_matches", as: :generate_group_matches_tournament
+ post "tournaments/:id/propose_knockout", to: "tournaments#propose_knockout", as: :propose_knockout_tournament
+ post "tournaments/:id/participants", to: "tournament_participants#create", as: :tournament_participants
+ patch "tournaments/:id/participants/:participant_id", to: "tournament_participants#update", as: :tournament_participant
+ delete "tournaments/:id/participants/:participant_id", to: "tournament_participants#destroy"
+ post "tournaments/:id/groups", to: "tournament_groups#create", as: :tournament_groups
+ delete "tournaments/:id/groups/:group_id", to: "tournament_groups#destroy", as: :tournament_group
+ post "tournaments/:id/matches", to: "tournament_matches#create", as: :tournament_matches
+ patch "tournaments/:id/matches/:match_id", to: "tournament_matches#update", as: :tournament_match
+ post "tournaments/:id/matches/:match_id/swap", to: "tournament_matches#swap", as: :swap_tournament_match
+ delete "tournaments/:id/matches/:match_id", to: "tournament_matches#destroy"
+ post "tournaments/:id/matches/:match_id/result", to: "tournament_results#create", as: :tournament_match_result
+ post "tournaments/:id/invitations", to: "tournament_invitations#create", as: :tournament_invitations
+ post "tournaments/:id/invite_draft", to: "tournament_invitations#save_draft", as: :tournament_invite_draft
+ post "tournaments/:id/invite_images", to: "tournament_invitations#upload_image", as: :tournament_invite_images
+ delete "tournaments/:id/invitations/:invitation_id", to: "tournament_invitations#destroy", as: :tournament_invitation
+ delete "tournaments/:id/assignments/:assignment_id", to: "tournament_assignments#destroy", as: :tournament_assignment
get "teams/:id/dettagli", to: "teams#details", as: :team_details
get "teams/:id/dashboard", to: "teams#dashboard"
get "teams/:id/roster", to: "teams#roster"
diff --git a/backend/db/migrate/20260903120000_create_tournaments.rb b/backend/db/migrate/20260903120000_create_tournaments.rb
new file mode 100644
index 0000000..398dc9e
--- /dev/null
+++ b/backend/db/migrate/20260903120000_create_tournaments.rb
@@ -0,0 +1,106 @@
+# frozen_string_literal: true
+
+class CreateTournaments < ActiveRecord::Migration[7.2]
+ def change
+ add_column :teams, :internal_kind, :string
+ add_index :teams, [:club_id, :internal_kind]
+
+ create_table :tournaments, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :club, type: :uuid, null: false, foreign_key: true
+ t.references :broadcast_team, type: :uuid, foreign_key: { to_table: :teams }
+ t.string :name, null: false
+ t.string :slug, null: false
+ t.string :sport_key, null: false
+ t.string :venue
+ t.jsonb :courts, null: false, default: []
+ t.date :starts_on, null: false
+ t.date :ends_on, null: false
+ t.string :format_kind, null: false, default: "mixed"
+ t.string :status, null: false, default: "draft"
+ t.text :description
+ t.integer :knockout_size
+ t.jsonb :scoring_settings, null: false, default: {}
+ t.timestamps
+ end
+ add_index :tournaments, :slug, unique: true
+ add_index :tournaments, [:club_id, :status]
+
+ create_table :tournament_groups, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :tournament, type: :uuid, null: false, foreign_key: true
+ t.string :name, null: false
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ create_table :tournament_rounds, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :tournament, type: :uuid, null: false, foreign_key: true
+ t.string :kind, null: false
+ t.string :name, null: false
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+
+ create_table :tournament_participants, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :tournament, type: :uuid, null: false, foreign_key: true
+ t.references :group, type: :uuid, foreign_key: { to_table: :tournament_groups }
+ t.references :source_team, type: :uuid, foreign_key: { to_table: :teams }
+ t.string :name, null: false
+ t.string :primary_color
+ t.string :secondary_color
+ t.string :logo_url
+ t.integer :seed
+ t.integer :position, null: false, default: 0
+ t.timestamps
+ end
+ add_index :tournament_participants, [:tournament_id, :name]
+
+ create_table :tournament_broadcast_invitations, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :tournament, type: :uuid, null: false, foreign_key: true
+ t.string :email, null: false
+ t.string :token_digest, null: false
+ t.string :scope_kind, null: false, default: "matches"
+ t.jsonb :match_ids, null: false, default: []
+ t.string :court
+ t.date :on_date
+ t.datetime :expires_at, null: false
+ t.datetime :accepted_at
+ t.references :accepted_by, type: :uuid, foreign_key: { to_table: :users }
+ t.timestamps
+ end
+ add_index :tournament_broadcast_invitations, :token_digest, unique: true
+ add_index :tournament_broadcast_invitations, [:tournament_id, :email]
+
+ add_reference :matches, :tournament, type: :uuid, foreign_key: true
+ add_reference :matches, :home_participant, type: :uuid, foreign_key: { to_table: :tournament_participants }
+ add_reference :matches, :away_participant, type: :uuid, foreign_key: { to_table: :tournament_participants }
+ add_reference :matches, :tournament_group, type: :uuid, foreign_key: true
+ add_reference :matches, :tournament_round, type: :uuid, foreign_key: true
+ add_column :matches, :court, :string
+ add_column :matches, :home_score, :integer
+ add_column :matches, :away_score, :integer
+ add_column :matches, :result_status, :string
+ add_column :matches, :result_source, :string
+ add_column :matches, :result_data, :jsonb, null: false, default: {}
+ add_column :matches, :winner_participant_id, :uuid
+ add_column :matches, :home_source_kind, :string
+ add_column :matches, :away_source_kind, :string
+ add_column :matches, :home_source_match_id, :uuid
+ add_column :matches, :away_source_match_id, :uuid
+ add_column :matches, :home_source_group_id, :uuid
+ add_column :matches, :away_source_group_id, :uuid
+ add_column :matches, :home_source_rank, :integer
+ add_column :matches, :away_source_rank, :integer
+ add_index :matches, [:tournament_id, :scheduled_at]
+ add_index :matches, [:tournament_id, :court]
+
+ create_table :tournament_broadcast_assignments, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
+ t.references :tournament, type: :uuid, null: false, foreign_key: true
+ t.references :match, type: :uuid, null: false, foreign_key: true
+ t.references :user, type: :uuid, null: false, foreign_key: true
+ t.references :invitation, type: :uuid, foreign_key: { to_table: :tournament_broadcast_invitations }
+ t.timestamps
+ end
+ add_index :tournament_broadcast_assignments, [:match_id, :user_id], unique: true,
+ name: "index_tournament_assignments_on_match_and_user"
+ end
+end
diff --git a/backend/db/migrate/20260904120000_add_note_and_email_html_to_tournament_invitations.rb b/backend/db/migrate/20260904120000_add_note_and_email_html_to_tournament_invitations.rb
new file mode 100644
index 0000000..94238f0
--- /dev/null
+++ b/backend/db/migrate/20260904120000_add_note_and_email_html_to_tournament_invitations.rb
@@ -0,0 +1,8 @@
+# frozen_string_literal: true
+
+class AddNoteAndEmailHtmlToTournamentInvitations < ActiveRecord::Migration[7.2]
+ def change
+ add_column :tournament_broadcast_invitations, :note, :text
+ add_column :tournament_broadcast_invitations, :email_html, :text
+ end
+end
diff --git a/backend/db/migrate/20260904143000_add_invite_draft_to_tournaments.rb b/backend/db/migrate/20260904143000_add_invite_draft_to_tournaments.rb
new file mode 100644
index 0000000..25e42cc
--- /dev/null
+++ b/backend/db/migrate/20260904143000_add_invite_draft_to_tournaments.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+class AddInviteDraftToTournaments < ActiveRecord::Migration[7.2]
+ def change
+ add_column :tournaments, :invite_draft_html, :text
+ add_column :tournaments, :invite_draft_note, :text
+ add_column :tournaments, :invite_draft_email, :string
+ add_column :tournaments, :invite_draft_saved_at, :datetime
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index a606e00..28ff744 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) do
+ActiveRecord::Schema[7.2].define(version: 2026_09_04_143000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -278,7 +278,34 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) do
t.jsonb "scoring_rules", default: {}, null: false
t.string "opponent_primary_color"
t.string "overlay_kind"
+ t.uuid "tournament_id"
+ t.uuid "home_participant_id"
+ t.uuid "away_participant_id"
+ t.uuid "tournament_group_id"
+ t.uuid "tournament_round_id"
+ t.string "court"
+ t.integer "home_score"
+ t.integer "away_score"
+ t.string "result_status"
+ t.string "result_source"
+ t.jsonb "result_data", default: {}, null: false
+ t.uuid "winner_participant_id"
+ t.string "home_source_kind"
+ t.string "away_source_kind"
+ t.uuid "home_source_match_id"
+ t.uuid "away_source_match_id"
+ t.uuid "home_source_group_id"
+ t.uuid "away_source_group_id"
+ t.integer "home_source_rank"
+ t.integer "away_source_rank"
+ t.index ["away_participant_id"], name: "index_matches_on_away_participant_id"
+ t.index ["home_participant_id"], name: "index_matches_on_home_participant_id"
t.index ["team_id"], name: "index_matches_on_team_id"
+ t.index ["tournament_group_id"], name: "index_matches_on_tournament_group_id"
+ t.index ["tournament_id", "court"], name: "index_matches_on_tournament_id_and_court"
+ t.index ["tournament_id", "scheduled_at"], name: "index_matches_on_tournament_id_and_scheduled_at"
+ t.index ["tournament_id"], name: "index_matches_on_tournament_id"
+ t.index ["tournament_round_id"], name: "index_matches_on_tournament_round_id"
end
create_table "ops_incidents", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
@@ -558,10 +585,111 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) do
t.text "description"
t.string "slug", null: false
t.boolean "roster_public", default: false, null: false
+ t.string "internal_kind"
+ t.index ["club_id", "internal_kind"], name: "index_teams_on_club_id_and_internal_kind"
t.index ["club_id"], name: "index_teams_on_club_id"
t.index ["slug"], name: "index_teams_on_slug", unique: true
end
+ create_table "tournament_broadcast_assignments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "tournament_id", null: false
+ t.uuid "match_id", null: false
+ t.uuid "user_id", null: false
+ t.uuid "invitation_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["invitation_id"], name: "index_tournament_broadcast_assignments_on_invitation_id"
+ t.index ["match_id", "user_id"], name: "index_tournament_assignments_on_match_and_user", unique: true
+ t.index ["match_id"], name: "index_tournament_broadcast_assignments_on_match_id"
+ t.index ["tournament_id"], name: "index_tournament_broadcast_assignments_on_tournament_id"
+ t.index ["user_id"], name: "index_tournament_broadcast_assignments_on_user_id"
+ end
+
+ create_table "tournament_broadcast_invitations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "tournament_id", null: false
+ t.string "email", null: false
+ t.string "token_digest", null: false
+ t.string "scope_kind", default: "matches", null: false
+ t.jsonb "match_ids", default: [], null: false
+ t.string "court"
+ t.date "on_date"
+ t.datetime "expires_at", null: false
+ t.datetime "accepted_at"
+ t.uuid "accepted_by_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.text "note"
+ t.text "email_html"
+ t.index ["accepted_by_id"], name: "index_tournament_broadcast_invitations_on_accepted_by_id"
+ t.index ["token_digest"], name: "index_tournament_broadcast_invitations_on_token_digest", unique: true
+ t.index ["tournament_id", "email"], name: "idx_on_tournament_id_email_3759dfe34b"
+ t.index ["tournament_id"], name: "index_tournament_broadcast_invitations_on_tournament_id"
+ end
+
+ create_table "tournament_groups", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "tournament_id", null: false
+ t.string "name", null: false
+ t.integer "position", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tournament_id"], name: "index_tournament_groups_on_tournament_id"
+ end
+
+ create_table "tournament_participants", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "tournament_id", null: false
+ t.uuid "group_id"
+ t.uuid "source_team_id"
+ t.string "name", null: false
+ t.string "primary_color"
+ t.string "secondary_color"
+ t.string "logo_url"
+ t.integer "seed"
+ t.integer "position", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["group_id"], name: "index_tournament_participants_on_group_id"
+ t.index ["source_team_id"], name: "index_tournament_participants_on_source_team_id"
+ t.index ["tournament_id", "name"], name: "index_tournament_participants_on_tournament_id_and_name"
+ t.index ["tournament_id"], name: "index_tournament_participants_on_tournament_id"
+ end
+
+ create_table "tournament_rounds", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "tournament_id", null: false
+ t.string "kind", null: false
+ t.string "name", null: false
+ t.integer "position", default: 0, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["tournament_id"], name: "index_tournament_rounds_on_tournament_id"
+ end
+
+ create_table "tournaments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.uuid "club_id", null: false
+ t.uuid "broadcast_team_id"
+ t.string "name", null: false
+ t.string "slug", null: false
+ t.string "sport_key", null: false
+ t.string "venue"
+ t.jsonb "courts", default: [], null: false
+ t.date "starts_on", null: false
+ t.date "ends_on", null: false
+ t.string "format_kind", default: "mixed", null: false
+ t.string "status", default: "draft", null: false
+ t.text "description"
+ t.integer "knockout_size"
+ t.jsonb "scoring_settings", default: {}, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.text "invite_draft_html"
+ t.text "invite_draft_note"
+ t.string "invite_draft_email"
+ t.datetime "invite_draft_saved_at"
+ t.index ["broadcast_team_id"], name: "index_tournaments_on_broadcast_team_id"
+ t.index ["club_id", "status"], name: "index_tournaments_on_club_id_and_status"
+ t.index ["club_id"], name: "index_tournaments_on_club_id"
+ t.index ["slug"], name: "index_tournaments_on_slug", unique: true
+ end
+
create_table "user_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false
t.uuid "team_id", null: false
@@ -617,6 +745,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) do
add_foreign_key "club_memberships", "users"
add_foreign_key "device_states", "stream_sessions"
add_foreign_key "matches", "teams"
+ add_foreign_key "matches", "tournament_groups"
+ add_foreign_key "matches", "tournament_participants", column: "away_participant_id"
+ add_foreign_key "matches", "tournament_participants", column: "home_participant_id"
+ add_foreign_key "matches", "tournament_rounds"
+ add_foreign_key "matches", "tournaments"
add_foreign_key "recordings", "stream_sessions"
add_foreign_key "recordings", "teams"
add_foreign_key "score_states", "stream_sessions"
@@ -636,6 +769,19 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) do
add_foreign_key "team_invitations", "teams"
add_foreign_key "team_roster_members", "teams"
add_foreign_key "teams", "clubs"
+ add_foreign_key "tournament_broadcast_assignments", "matches"
+ add_foreign_key "tournament_broadcast_assignments", "tournament_broadcast_invitations", column: "invitation_id"
+ add_foreign_key "tournament_broadcast_assignments", "tournaments"
+ add_foreign_key "tournament_broadcast_assignments", "users"
+ add_foreign_key "tournament_broadcast_invitations", "tournaments"
+ add_foreign_key "tournament_broadcast_invitations", "users", column: "accepted_by_id"
+ add_foreign_key "tournament_groups", "tournaments"
+ add_foreign_key "tournament_participants", "teams", column: "source_team_id"
+ add_foreign_key "tournament_participants", "tournament_groups", column: "group_id"
+ add_foreign_key "tournament_participants", "tournaments"
+ add_foreign_key "tournament_rounds", "tournaments"
+ add_foreign_key "tournaments", "clubs"
+ add_foreign_key "tournaments", "teams", column: "broadcast_team_id"
add_foreign_key "user_teams", "teams"
add_foreign_key "user_teams", "users"
add_foreign_key "youtube_credentials", "clubs"
diff --git a/backend/public/marketing.css b/backend/public/marketing.css
index e0c3aba..d016ad1 100644
--- a/backend/public/marketing.css
+++ b/backend/public/marketing.css
@@ -430,6 +430,28 @@ body.nav-menu-open { overflow: hidden; }
}
.btn-secondary { background: #252530; color: #fff; }
.btn-secondary:hover { background: #333; text-decoration: none; color: #fff; }
+.btn-gated {
+ position: relative;
+ overflow: hidden;
+ padding-right: 28px;
+}
+.btn-gated__ribbon {
+ position: absolute;
+ top: 7px;
+ right: -20px;
+ width: 62px;
+ padding: 2px 0;
+ transform: rotate(45deg);
+ background: #ffd54f;
+ color: #1a1408;
+ font-size: 0.55rem;
+ font-weight: 800;
+ letter-spacing: 0.06em;
+ line-height: 1.15;
+ text-align: center;
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
+ pointer-events: none;
+}
.form-section-title { font-size: 1.05rem; margin: 0 0 12px; color: #e8e8ee; }
.branding-fields { border: 1px solid #252530; border-radius: 10px; padding: 16px 18px; margin: 16px 0; }
.branding-fields legend { padding: 0 8px; color: #ccc; font-size: 0.9rem; }
@@ -1008,6 +1030,746 @@ body.nav-menu-open { overflow: hidden; }
.team-dashboard-photo { margin: 12px 0 16px; }
.team-dashboard-photo img { max-height: 200px; }
+.tournament-new__lead {
+ color: #888;
+ max-width: 52rem;
+ margin: 0 0 4px;
+}
+.tournament-form {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 16px 24px;
+ align-items: start;
+}
+.tournament-form__field { min-width: 0; }
+.tournament-form__field input,
+.tournament-form__field select,
+.tournament-form__field textarea {
+ margin-bottom: 0;
+}
+.tournament-form textarea {
+ width: 100%;
+ padding: 12px 14px;
+ border-radius: 8px;
+ border: 1px solid #3d3d4a;
+ background: #1c1c26;
+ color: #fff;
+ font-size: 1rem;
+ font-family: inherit;
+ line-height: 1.45;
+ resize: vertical;
+ min-height: 88px;
+}
+.tournament-form textarea:focus {
+ outline: none;
+ border-color: #e53935;
+ box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
+}
+.tournament-form__full,
+.tournament-form__actions {
+ grid-column: 1 / -1;
+}
+.tournament-form__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ justify-content: flex-start;
+}
+.tournament-form__actions .btn {
+ width: auto;
+}
+@media (max-width: 699px) {
+ .tournament-form { grid-template-columns: 1fr; }
+}
+.tournament-invite-matches {
+ max-height: 220px;
+ overflow: auto;
+ padding: 8px 0;
+}
+.tournament-invite-matches__row {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 6px;
+ color: #ddd;
+}
+.tournament-invite-matches__row input {
+ width: auto;
+ margin: 0;
+}
+.invite-editor {
+ margin-top: 8px;
+}
+.invite-editor__toolbar {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ padding: 8px;
+ background: #16161e;
+ border: 1px solid #3d3d4a;
+ border-bottom: 0;
+ border-radius: 8px 8px 0 0;
+}
+.invite-editor__btn {
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ margin: 0;
+ border: 1px solid #3d3d4a;
+ border-radius: 6px;
+ background: #1c1c26;
+ color: #eee;
+ cursor: pointer;
+}
+.invite-editor__btn:hover {
+ border-color: #e53935;
+ color: #fff;
+}
+.invite-editor__frame {
+ position: relative;
+}
+.invite-editor__surface {
+ min-height: 280px;
+ padding: 16px 18px;
+ background: #fff;
+ color: #1a1a1a;
+ border: 1px solid #3d3d4a;
+ border-radius: 0 0 8px 8px;
+ font-size: 16px;
+ line-height: 1.5;
+ overflow: auto;
+}
+.invite-editor__surface:focus {
+ outline: none;
+ border-color: #e53935;
+ box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
+}
+.invite-editor__surface--drop {
+ outline: 2px dashed #e53935;
+}
+.invite-editor__surface p { margin: 0 0 12px; }
+.invite-editor__surface img {
+ max-width: 100%;
+ height: auto;
+ cursor: nwse-resize;
+ vertical-align: middle;
+}
+.invite-editor__img--selected {
+ outline: 2px solid #e53935;
+ outline-offset: 2px;
+}
+.invite-editor__handle {
+ position: absolute;
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ background: #e53935;
+ border: 2px solid #fff;
+ border-radius: 2px;
+ cursor: nwse-resize;
+ box-shadow: 0 0 0 1px #e53935;
+ z-index: 2;
+}
+.invite-editor__status {
+ margin: 8px 0 0;
+ font-size: 0.85rem;
+ color: #aaa;
+}
+.invite-editor__status--error { color: #ef9a9a; }
+body.invite-editor--resizing {
+ cursor: nwse-resize;
+ user-select: none;
+}
+
+.tournament-hub-hint { color: #888; margin: 0 0 12px; }
+.tournament-table-wrap { overflow-x: auto; margin-top: 8px; }
+.tournament-index-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ justify-content: flex-end;
+}
+.tournament-index-actions form { margin: 0; }
+.tournament-team-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ min-width: 0;
+}
+.tournament-team-chip img {
+ width: 28px;
+ height: 28px;
+ border-radius: 6px;
+ object-fit: contain;
+ background: #12121a;
+ flex-shrink: 0;
+}
+.tournament-match-table .tournament-match-form__datetime {
+ min-width: 190px;
+ margin-bottom: 0;
+ padding: 8px 10px;
+ font-size: 0.9rem;
+}
+.tournament-match-table .tournament-match-form__court {
+ min-width: 120px;
+ margin-bottom: 0;
+ padding: 8px 10px;
+ font-size: 0.9rem;
+}
+.tournament-match-table .tournament-match-form__team {
+ min-width: 160px;
+ max-width: 220px;
+ margin-bottom: 0;
+ padding: 8px 10px;
+ font-size: 0.9rem;
+}
+.tournament-knockout-help {
+ margin-bottom: 20px;
+ padding-bottom: 16px;
+ border-bottom: 1px solid #2a2a36;
+}
+.tournament-knockout-help__steps {
+ margin: 8px 0 16px 1.2rem;
+ color: #c4c4d0;
+ line-height: 1.45;
+}
+.tournament-knockout-source {
+ margin-top: 4px;
+ color: #888;
+ font-size: 0.8rem;
+}
+.tournament-match-form__score {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+.tournament-match-form__score input {
+ width: 56px;
+ margin-bottom: 0;
+ padding: 8px 6px;
+}
+.tournament-match-form__actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+ align-items: center;
+}
+.tournament-match-form__save {
+ width: auto;
+ padding: 4px 10px;
+ font-size: 0.8rem;
+ margin: 0;
+}
+.tournament-match-swap-col {
+ width: 44px;
+ padding-left: 4px;
+ padding-right: 4px;
+ text-align: center;
+ vertical-align: middle;
+}
+.tournament-match-swap__form { margin: 0; display: inline; }
+.tournament-match-swap__btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ padding: 0;
+ border: 0;
+ border-radius: 8px;
+ background: #252530;
+ color: #e8e8ee;
+ cursor: pointer;
+}
+.tournament-match-swap__btn:hover {
+ background: #333;
+ color: #fff;
+}
+.tournament-match-swap__btn:disabled {
+ opacity: 0.55;
+ cursor: wait;
+}
+.tournament-match-table [data-swap-side].is-swapped .tournament-team-chip {
+ animation: tournament-chip-swap 180ms ease;
+}
+@keyframes tournament-chip-swap {
+ from { opacity: 0.35; transform: translateX(8px); }
+ to { opacity: 1; transform: none; }
+}
+.tournament-streaming-cell {
+ min-width: 200px;
+ vertical-align: middle;
+}
+.tournament-streaming-chip {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 6px;
+ padding: 4px 8px;
+ border-radius: 8px;
+ font-size: 0.78rem;
+ line-height: 1.3;
+ background: #1c1c24;
+}
+.tournament-streaming-chip--assigned { color: #a5d6a7; }
+.tournament-streaming-chip--pending { color: #ffcc80; }
+.tournament-streaming-chip__name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.tournament-streaming-chip__status {
+ color: inherit;
+ opacity: 0.8;
+ font-size: 0.72rem;
+ flex-shrink: 0;
+}
+.tournament-streaming-chip__form { margin: 0; display: inline; }
+.tournament-streaming-chip__action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 22px;
+ height: 22px;
+ margin-left: auto;
+ padding: 0;
+ border: 0;
+ border-radius: 6px;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+.tournament-streaming-chip__action:hover {
+ background: #333;
+ color: #fff;
+}
+.tournament-streaming-empty {
+ color: #888;
+ font-size: 0.8rem;
+}
+.tournament-streaming-form {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px;
+ margin: 0;
+}
+.tournament-streaming-form__email {
+ width: 150px;
+ margin-bottom: 0;
+ padding: 6px 8px;
+ font-size: 0.8rem;
+}
+.tournament-stream-plan__title {
+ margin: 0 0 8px;
+ font-size: 1.15rem;
+}
+.tournament-stream-plan__header {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.tournament-stream-plan__jump {
+ width: auto;
+ padding: 8px 14px;
+ font-size: 0.85rem;
+ margin: 0;
+ flex-shrink: 0;
+}
+.tournament-stream-plan__stats {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ align-items: center;
+ margin: 16px 0;
+}
+.tournament-stream-plan__stat {
+ display: inline-flex;
+ align-items: center;
+ padding: 4px 10px;
+ border-radius: 999px;
+ font-size: 0.8rem;
+ background: #1c1c24;
+}
+.tournament-stream-plan__stat--assigned { color: #a5d6a7; }
+.tournament-stream-plan__stat--pending { color: #ffcc80; }
+.tournament-stream-plan__stat--open { color: #ef9a9a; }
+.tournament-stream-plan__stat--live { color: #ff8a80; }
+.tournament-stream-plan__stat--waiting_time { color: #80cbc4; }
+.tournament-stream-plan__stat--waiting_operator { color: #ffcc80; }
+.tournament-stream-plan__stat--ended { color: #bdbdbd; }
+.tournament-stream-plan__stat--uncovered { color: #ef9a9a; }
+.tournament-stream-plan__select {
+ width: auto;
+ padding: 4px 10px;
+ font-size: 0.8rem;
+ margin: 0;
+}
+.tournament-stream-plan__invite-card {
+ margin-top: 20px;
+}
+.tournament-stream-plan__invite {
+ margin-top: 8px;
+}
+.tournament-stream-plan__mail {
+ margin-top: 8px;
+}
+.tournament-stream-plan__mail-title {
+ margin: 0 0 6px;
+ font-size: 1rem;
+}
+.tournament-stream-plan__extra {
+ margin-top: 12px;
+ color: #ddd;
+}
+.tournament-stream-plan__extra summary {
+ cursor: pointer;
+ font-weight: 600;
+ margin-bottom: 8px;
+}
+.tournament-stream-plan__day {
+ margin: 28px 0 10px;
+ font-size: 1rem;
+ color: #e8e8ee;
+}
+.tournament-stream-plan__check-col {
+ width: 36px;
+ text-align: center;
+ vertical-align: middle;
+}
+.tournament-stream-plan__check-col input {
+ width: auto;
+ margin: 0;
+}
+.tournament-stream-plan__check {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ margin: 0;
+}
+.tournament-stream-plan__matchup {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.tournament-stream-plan__vs {
+ color: #888;
+}
+.tournament-stream-plan__row--uncovered td {
+ background: rgba(239, 154, 154, 0.04);
+}
+.tournament-stream-plan__row--live td {
+ background: rgba(229, 57, 53, 0.08);
+}
+.tournament-stream-plan__table .tournament-streaming-cell {
+ min-width: 180px;
+}
+.tournament-live-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.8rem;
+ font-weight: 600;
+ white-space: nowrap;
+}
+.tournament-live-status__dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: currentColor;
+ flex-shrink: 0;
+}
+.tournament-live-status--live { color: #ff8a80; }
+.tournament-live-status--live .tournament-live-status__dot {
+ animation: tournament-live-pulse 1.2s ease-in-out infinite;
+}
+.tournament-live-status--waiting_time { color: #80cbc4; }
+.tournament-live-status--waiting_operator { color: #ffcc80; }
+.tournament-live-status--ended { color: #bdbdbd; }
+.tournament-live-status--uncovered { color: #ef9a9a; }
+.tournament-live-status--replay { color: #ce93d8; }
+.tournament-live-status--scheduled { color: #80cbc4; }
+.tournament-live-status--waiting { color: #ffcc80; }
+.tournament-board { width: 100%; }
+.tournament-board__day {
+ margin: 20px 0 8px;
+ font-size: 1.05rem;
+}
+.tournament-board__phase {
+ margin: 4px 0 0;
+ font-size: 0.78rem;
+ color: #888;
+}
+.tournament-board__actions {
+ white-space: nowrap;
+ text-align: right;
+}
+.tournament-board__cta {
+ display: inline-block;
+ padding: 6px 12px;
+ border-radius: 8px;
+ background: #2a2a36;
+ color: #fff;
+ text-decoration: none;
+ font-size: 0.82rem;
+ font-weight: 600;
+}
+.tournament-board__cta:hover { background: #3a3a48; }
+.tournament-board__cta--live {
+ background: #e53935;
+}
+.tournament-board__cta--live:hover { background: #c62828; }
+.tournament-board__row--live { background: rgba(229, 57, 53, 0.08); }
+.tournament-directory-follow {
+ font-size: 0.78rem;
+ color: #e53935;
+ font-weight: 600;
+}
+.tournament-public-tabs {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ margin: 0 0 20px;
+}
+.tournament-public-tabs .btn {
+ padding: 8px 16px;
+ font-size: 0.9rem;
+}
+.tournament-public-hero.roster-hero {
+ flex-direction: row;
+ align-items: center;
+ gap: 14px;
+ padding: 12px 16px;
+ margin-bottom: 16px;
+ overflow: visible;
+}
+.tournament-public-hero .roster-hero__intro {
+ padding: 0;
+ min-width: 0;
+ flex: 1;
+}
+.tournament-public-hero .roster-hero__club {
+ margin: 0 0 2px;
+ font-size: 0.75rem;
+}
+.tournament-public-hero .roster-hero__title {
+ margin: 0 0 2px;
+ font-size: 1.25rem;
+ line-height: 1.2;
+}
+.tournament-public-hero .team-public-meta {
+ margin: 0;
+ font-size: 0.82rem;
+}
+.tournament-public-hero__manage {
+ margin-left: auto;
+ flex-shrink: 0;
+ padding: 6px 12px;
+ font-size: 0.85rem;
+}
+.tournament-public-hero__logo {
+ width: 52px;
+ height: 52px;
+ border-radius: 10px;
+ object-fit: contain;
+ background: #111;
+ flex-shrink: 0;
+ border: 1px solid #2a2a36;
+}
+.tournament-public-hero__logo--fallback {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-weight: 700;
+ font-size: 1.2rem;
+ color: var(--club-primary, #e53935);
+ background: #1f1f28;
+}
+.tournament-directory-card {
+ padding: 12px 14px;
+ align-items: center;
+}
+.tournament-directory-card .team-directory-card__photo {
+ width: 56px;
+ height: 56px;
+}
+.tournament-directory-card .team-directory-card__club {
+ margin: 0 0 2px;
+}
+.tournament-directory-card .team-directory-card__name {
+ margin: 0 0 2px;
+ font-size: 1rem;
+ line-height: 1.2;
+}
+.tournament-directory-card .team-directory-card__meta {
+ margin: 0 0 6px;
+ font-size: 0.8rem;
+}
+.tournament-hub-header {
+ margin: 16px 0 20px;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px 16px;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+.tournament-hub-header__identity {
+ display: flex;
+ gap: 14px;
+ align-items: center;
+ min-width: 0;
+}
+.tournament-hub-header__text h1 {
+ margin: 0 0 4px;
+ font-size: 1.4rem;
+ line-height: 1.2;
+}
+.tournament-hub-header__text p { color: #888; margin: 0; }
+.tournament-hub-header__actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+.tournament-hub-logo {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 8px;
+ flex-shrink: 0;
+}
+.tournament-hub-logo__preview {
+ width: 64px;
+ height: 64px;
+ border-radius: 10px;
+ border: 1px dashed #3d3d4a;
+ background: #12121a;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+}
+.tournament-hub-logo__preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.tournament-hub-logo__empty {
+ color: #e53935;
+ font-weight: 700;
+ font-size: 1.4rem;
+}
+.tournament-hub-logo__form { margin: 0; }
+.tournament-hub-logo__btn {
+ position: relative;
+ overflow: hidden;
+ width: auto;
+ margin: 0;
+ padding: 6px 10px;
+ font-size: 0.78rem;
+ cursor: pointer;
+}
+.tournament-hub-logo__btn input[type="file"] {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+ margin: 0;
+ width: 100%;
+ height: 100%;
+ font-size: 0;
+}
+@keyframes tournament-live-pulse {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0.3; }
+}
+.tournament-schedule-extra {
+ margin-top: 24px;
+ color: #ddd;
+}
+.tournament-schedule-extra summary {
+ cursor: pointer;
+ font-weight: 600;
+}
+.tournament-logo-upload { margin: 0; }
+.tournament-logo-upload__label {
+ display: block;
+ font-size: 0.8rem;
+ margin-bottom: 4px;
+ color: #aaa;
+}
+.tournament-logo-upload__input {
+ margin-bottom: 0;
+ padding: 6px 8px;
+ font-size: 0.8rem;
+}
+.tournament-public-matchup {
+ display: inline-flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 8px;
+}
+.tournament-squad-table { margin-top: 16px; }
+.tournament-squad-logo {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+.tournament-squad-logo__preview {
+ width: 48px;
+ height: 48px;
+ border-radius: 8px;
+ border: 1px dashed #3d3d4a;
+ background: #12121a;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ flex-shrink: 0;
+}
+.tournament-squad-logo__preview img {
+ width: 100%;
+ height: 100%;
+ object-fit: contain;
+}
+.tournament-squad-logo__empty {
+ color: #888;
+ font-size: 1.4rem;
+ line-height: 1;
+}
+.tournament-squad-logo__btn {
+ position: relative;
+ overflow: hidden;
+ width: auto;
+ margin: 0;
+ padding: 6px 12px;
+ font-size: 0.8rem;
+ cursor: pointer;
+}
+.tournament-squad-logo__btn input[type="file"] {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ cursor: pointer;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ font-size: 0;
+}
+.tournament-squad-name,
+.tournament-squad-group {
+ margin-bottom: 0;
+ min-width: 160px;
+ padding: 8px 10px;
+ font-size: 0.95rem;
+}
+
.roster-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
diff --git a/backend/public/tournament-calendar.js b/backend/public/tournament-calendar.js
new file mode 100644
index 0000000..1d31242
--- /dev/null
+++ b/backend/public/tournament-calendar.js
@@ -0,0 +1,124 @@
+(function () {
+ document.addEventListener(
+ "submit",
+ function (event) {
+ var form = event.target;
+ if (!(form instanceof HTMLFormElement)) return;
+ if (!form.classList.contains("tournament-match-swap__form")) return;
+
+ event.preventDefault();
+ event.stopImmediatePropagation();
+ if (form.dataset.swapBusy === "1") return;
+
+ var row = form.closest("tr");
+ if (!row) return;
+
+ form.dataset.swapBusy = "1";
+ var btn = form.querySelector("button");
+ if (btn) btn.disabled = true;
+
+ swapRowSides(row);
+
+ var token = document.querySelector('meta[name="csrf-token"]');
+ fetch(form.action, {
+ method: "POST",
+ headers: {
+ Accept: "application/json",
+ "X-Requested-With": "XMLHttpRequest",
+ "X-CSRF-Token": token ? token.content : ""
+ },
+ body: new FormData(form),
+ credentials: "same-origin"
+ })
+ .then(function (res) {
+ return res
+ .json()
+ .then(function (data) {
+ return { ok: res.ok && data && data.ok !== false, data: data || {} };
+ })
+ .catch(function () {
+ return { ok: false, data: {} };
+ });
+ })
+ .then(function (result) {
+ if (!result.ok) {
+ swapRowSides(row);
+ showSwapError(form, result.data.error);
+ return;
+ }
+ if (result.data.matchup_label) {
+ var del = row.querySelector(".tournament-match-delete-form");
+ if (del) {
+ del.setAttribute("data-turbo-confirm", result.data.matchup_label);
+ del.setAttribute("data-confirm", result.data.matchup_label);
+ }
+ }
+ })
+ .catch(function () {
+ swapRowSides(row);
+ showSwapError(form, null);
+ })
+ .finally(function () {
+ delete form.dataset.swapBusy;
+ if (btn) btn.disabled = false;
+ });
+ },
+ true
+ );
+
+ function swapRowSides(row) {
+ var home = row.querySelector("[data-swap-side='home']");
+ var away = row.querySelector("[data-swap-side='away']");
+ if (home && away) {
+ var tmp = home.innerHTML;
+ home.innerHTML = away.innerHTML;
+ away.innerHTML = tmp;
+ pulse(home);
+ pulse(away);
+ }
+
+ var homeScore = row.querySelector("[data-swap-score='home']");
+ var awayScore = row.querySelector("[data-swap-score='away']");
+ if (homeScore && awayScore) {
+ var hv = homeScore.value;
+ homeScore.value = awayScore.value;
+ awayScore.value = hv;
+ }
+
+ var played = row.querySelector("[data-swap-played-score]");
+ if (played) {
+ var parts = played.textContent.trim().split(/\s*[–-]\s*/);
+ if (parts.length === 2) {
+ played.textContent = parts[1] + "–" + parts[0];
+ }
+ }
+ }
+
+ function pulse(el) {
+ el.classList.remove("is-swapped");
+ void el.offsetWidth;
+ el.classList.add("is-swapped");
+ }
+
+ function showSwapError(form, message) {
+ var table = form.closest(".tournament-match-table");
+ var text =
+ message ||
+ (table && table.getAttribute("data-swap-error")) ||
+ "Impossibile invertire casa e ospite.";
+ var host = document.querySelector(".tournament-hub") || document.querySelector("main");
+ if (!host) return;
+
+ var prev = host.querySelector(".flash.alert[data-swap-flash]");
+ if (prev) prev.remove();
+
+ var el = document.createElement("div");
+ el.className = "flash alert";
+ el.setAttribute("data-swap-flash", "1");
+ el.textContent = text;
+ host.insertBefore(el, host.firstChild);
+ window.setTimeout(function () {
+ if (el.parentNode) el.remove();
+ }, 5000);
+ }
+})();
diff --git a/backend/public/tournament-invite-editor.js b/backend/public/tournament-invite-editor.js
new file mode 100644
index 0000000..efdeb35
--- /dev/null
+++ b/backend/public/tournament-invite-editor.js
@@ -0,0 +1,263 @@
+(function () {
+ var MIN_WIDTH = 80;
+ var MAX_WIDTH = 560;
+
+ function csrfToken() {
+ var meta = document.querySelector("meta[name='csrf-token']");
+ return meta ? meta.getAttribute("content") : "";
+ }
+
+ function initEditor(root) {
+ if (root.dataset.ready === "1") return;
+ root.dataset.ready = "1";
+
+ var surface = root.querySelector(".invite-editor__surface");
+ var handle = root.querySelector(".invite-editor__handle");
+ var fileInput = root.querySelector("[data-invite-file]");
+ var htmlField = root.closest("form").querySelector("[data-invite-html]");
+ var noteInput = root.closest("form").querySelector("[data-invite-note]");
+ var status = root.querySelector("[data-invite-status]");
+ var selectedImg = null;
+ var drag = null;
+
+ function showStatus(message, isError) {
+ if (!status) return;
+ if (!message) {
+ status.hidden = true;
+ status.textContent = "";
+ return;
+ }
+ status.hidden = false;
+ status.textContent = message;
+ status.classList.toggle("invite-editor__status--error", !!isError);
+ }
+
+ function syncHtml() {
+ if (htmlField) htmlField.value = surface.innerHTML;
+ }
+
+ function syncNote() {
+ var note = (noteInput && noteInput.value || "").trim();
+ var el = surface.querySelector("[data-invite-note]");
+ if (!note) {
+ if (el) el.remove();
+ syncHtml();
+ return;
+ }
+ if (!el) {
+ el = document.createElement("p");
+ el.setAttribute("data-invite-note", "");
+ var first = surface.querySelector("p");
+ if (first && first.nextSibling) surface.insertBefore(el, first.nextSibling);
+ else if (first) first.after(el);
+ else surface.appendChild(el);
+ }
+ el.textContent = note;
+ syncHtml();
+ }
+
+ function prepareImage(img) {
+ img.draggable = false;
+ img.style.maxWidth = "100%";
+ img.style.height = "auto";
+ if (!img.getAttribute("width") && img.naturalWidth) {
+ img.setAttribute("width", Math.min(img.naturalWidth, MAX_WIDTH));
+ }
+ }
+
+ function updateHandle() {
+ if (!selectedImg || !handle) return;
+ var frame = handle.parentElement.getBoundingClientRect();
+ var rect = selectedImg.getBoundingClientRect();
+ handle.hidden = false;
+ handle.style.left = rect.right - frame.left - 8 + "px";
+ handle.style.top = rect.bottom - frame.top - 8 + "px";
+ }
+
+ function clearSelection() {
+ selectedImg = null;
+ if (handle) handle.hidden = true;
+ surface.querySelectorAll("img.invite-editor__img--selected").forEach(function (img) {
+ img.classList.remove("invite-editor__img--selected");
+ });
+ }
+
+ function selectImage(img) {
+ clearSelection();
+ selectedImg = img;
+ img.classList.add("invite-editor__img--selected");
+ updateHandle();
+ }
+
+ function insertImage(url) {
+ surface.focus();
+ document.execCommand("insertHTML", false, '

');
+ var images = surface.querySelectorAll("img");
+ var img = images[images.length - 1];
+ if (img) {
+ prepareImage(img);
+ selectImage(img);
+ }
+ syncHtml();
+ }
+
+ function uploadFile(file) {
+ if (!file || !file.type.match(/^image\/(png|jpeg|webp|gif)$/)) {
+ showStatus(root.getAttribute("data-msg-type"), true);
+ return;
+ }
+ if (file.size > 2 * 1024 * 1024) {
+ showStatus(root.getAttribute("data-msg-size"), true);
+ return;
+ }
+ var data = new FormData();
+ data.append("file", file);
+ showStatus(root.getAttribute("data-msg-uploading"), false);
+ fetch(root.getAttribute("data-upload-url"), {
+ method: "POST",
+ headers: { "X-CSRF-Token": csrfToken(), Accept: "application/json" },
+ body: data,
+ credentials: "same-origin"
+ }).then(function (res) {
+ return res.json().then(function (json) {
+ if (!res.ok) throw new Error(json.error || root.getAttribute("data-msg-failed"));
+ return json;
+ });
+ }).then(function (json) {
+ showStatus("", false);
+ insertImage(json.url);
+ }).catch(function (err) {
+ showStatus(err.message || root.getAttribute("data-msg-failed"), true);
+ });
+ }
+
+ root.querySelectorAll("[data-invite-cmd]").forEach(function (btn) {
+ btn.addEventListener("mousedown", function (e) {
+ e.preventDefault();
+ });
+ btn.addEventListener("click", function (e) {
+ e.preventDefault();
+ var cmd = btn.getAttribute("data-invite-cmd");
+ surface.focus();
+ if (cmd === "createLink") {
+ var url = window.prompt(root.getAttribute("data-msg-link") || "URL", "https://");
+ if (url) document.execCommand("createLink", false, url);
+ } else if (cmd === "image") {
+ fileInput.click();
+ } else {
+ document.execCommand(cmd, false, null);
+ }
+ syncHtml();
+ });
+ });
+
+ fileInput.addEventListener("change", function () {
+ var file = fileInput.files && fileInput.files[0];
+ fileInput.value = "";
+ if (file) uploadFile(file);
+ });
+
+ surface.addEventListener("click", function (e) {
+ if (e.target.tagName === "IMG") {
+ e.preventDefault();
+ selectImage(e.target);
+ } else {
+ clearSelection();
+ }
+ });
+
+ surface.addEventListener("input", syncHtml);
+ surface.addEventListener("blur", syncHtml);
+ if (noteInput) noteInput.addEventListener("input", syncNote);
+
+ surface.addEventListener("paste", function (e) {
+ var items = e.clipboardData && e.clipboardData.files;
+ if (items && items.length && items[0].type.indexOf("image/") === 0) {
+ e.preventDefault();
+ uploadFile(items[0]);
+ }
+ });
+
+ ["dragenter", "dragover"].forEach(function (type) {
+ surface.addEventListener(type, function (e) {
+ e.preventDefault();
+ surface.classList.add("invite-editor__surface--drop");
+ });
+ });
+ ["dragleave", "drop"].forEach(function (type) {
+ surface.addEventListener(type, function () {
+ surface.classList.remove("invite-editor__surface--drop");
+ });
+ });
+ surface.addEventListener("drop", function (e) {
+ e.preventDefault();
+ var file = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
+ if (file) uploadFile(file);
+ });
+
+ handle.addEventListener("mousedown", function (e) {
+ if (!selectedImg) return;
+ e.preventDefault();
+ e.stopPropagation();
+ drag = {
+ startX: e.clientX,
+ startWidth: selectedImg.getBoundingClientRect().width
+ };
+ document.body.classList.add("invite-editor--resizing");
+ });
+
+ document.addEventListener("mousemove", function (e) {
+ if (!drag || !selectedImg) return;
+ var width = Math.round(Math.min(MAX_WIDTH, Math.max(MIN_WIDTH, drag.startWidth + (e.clientX - drag.startX))));
+ selectedImg.style.width = width + "px";
+ selectedImg.style.height = "auto";
+ selectedImg.setAttribute("width", String(width));
+ selectedImg.removeAttribute("height");
+ updateHandle();
+ });
+
+ document.addEventListener("mouseup", function () {
+ if (!drag) return;
+ drag = null;
+ document.body.classList.remove("invite-editor--resizing");
+ syncHtml();
+ });
+
+ window.addEventListener("resize", updateHandle);
+ surface.querySelectorAll("img").forEach(prepareImage);
+
+ var form = root.closest("form");
+ form.addEventListener("submit", function () {
+ syncNote();
+ syncHtml();
+ });
+ form.addEventListener("turbo:submit-start", function () {
+ syncNote();
+ syncHtml();
+ });
+ syncNote();
+ syncHtml();
+ }
+
+ function bindSelectUncovered() {
+ document.querySelectorAll("[data-select-uncovered]").forEach(function (btn) {
+ if (btn.dataset.boundSelect === "1") return;
+ btn.dataset.boundSelect = "1";
+ btn.addEventListener("click", function (e) {
+ e.preventDefault();
+ var formId = btn.getAttribute("data-select-uncovered");
+ document.querySelectorAll('input[data-uncovered-match][form="' + formId + '"]').forEach(function (cb) {
+ cb.checked = true;
+ });
+ });
+ });
+ }
+
+ function boot() {
+ document.querySelectorAll("[data-invite-editor]").forEach(initEditor);
+ bindSelectUncovered();
+ }
+
+ document.addEventListener("DOMContentLoaded", boot);
+ document.addEventListener("turbo:load", boot);
+})();
diff --git a/backend/spec/requests/admin/club_recordings_spec.rb b/backend/spec/requests/admin/club_recordings_spec.rb
index 69f8151..26cd069 100644
--- a/backend/spec/requests/admin/club_recordings_spec.rb
+++ b/backend/spec/requests/admin/club_recordings_spec.rb
@@ -41,11 +41,15 @@ RSpec.describe "Admin club replays", type: :request do
end
it "elimina un replay" do
+ storage = instance_double(Recordings::Storage, delete: true)
+ allow(Recordings::Storage).to receive(:new).and_return(storage)
+
delete admin_club_recording_path(club, recording)
expect(response).to redirect_to(admin_club_recordings_path(club))
expect(recording.reload.status).to eq("expired")
expect(recording.deleted_at).to be_present
+ expect(storage).to have_received(:delete).at_least(:once)
end
it "prolunga la scadenza con giorni aggiuntivi" do
diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb
index aba59c7..12c6af9 100644
--- a/backend/spec/requests/public/regia_spec.rb
+++ b/backend/spec/requests/public/regia_spec.rb
@@ -145,9 +145,13 @@ RSpec.describe "Public regia", type: :request do
set_always_available: true,
set_path_recording: true,
delete_path: true,
- list_paths: []
+ list_paths: [],
+ ensure_path_with_slate!: true,
+ disable_recording_if_active!: true
)
allow(Mediamtx::Client).to receive(:new).and_return(mtx)
+ allow(Mediamtx::Client).to receive(:for_session).and_return(mtx)
+ allow(Mediamtx::PublisherSync).to receive(:remember_recording_off!)
token = Sessions::RegiaAccess.new(session).issue_token!
post public_regia_pause_path(token)
diff --git a/backend/spec/requests/public/tournaments_spec.rb b/backend/spec/requests/public/tournaments_spec.rb
new file mode 100644
index 0000000..18e1159
--- /dev/null
+++ b/backend/spec/requests/public/tournaments_spec.rb
@@ -0,0 +1,783 @@
+require "rails_helper"
+require "base64"
+
+RSpec.describe "Tournaments", type: :request do
+ let!(:coach) do
+ User.create!(email: "torneo-owner@test.it", name: "Owner", password: "Password123", role: "coach")
+ end
+ let!(:club) do
+ c = Club.create!(name: "Torneo Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
+ ClubMembership.create!(user: coach, club: c, role: "owner")
+ c
+ end
+ let!(:team) { club.teams.create!(name: "U15", sport: "volleyball") }
+
+ def login!
+ post public_login_path, params: { email: coach.email, password: "Password123" }
+ end
+
+ def assign_plan!(slug)
+ Billing::AssignPlan.call(club: club, plan_slug: slug)
+ club.reload
+ end
+
+ it "non crea tornei senza Premium Full" do
+ assign_plan!("premium_light")
+ login!
+ expect {
+ post public_club_tournaments_path(club), params: {
+ tournament: {
+ name: "Memorial",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current + 1,
+ format_kind: "mixed"
+ }
+ }
+ }.not_to change(Tournament, :count)
+ expect(response).to redirect_to(public_club_billing_path(club))
+ end
+
+ it "sulla società Free il pulsante Tornei ha il nastrino FULL" do
+ assign_plan!("free")
+ login!
+ get public_club_path(club)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("btn-gated__ribbon")
+ expect(response.body).to include(">FULL<")
+ expect(response.body).to include(public_club_billing_path(club))
+ expect(response.body).not_to include(%(href="#{public_club_tournaments_path(club)}"))
+ end
+
+ it "dalla società apre l'elenco tornei, non in dashboard" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Open Elenco",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current + 1,
+ format_kind: "free"
+ }
+ )
+
+ get public_club_path(club)
+ expect(response.body).to include(public_club_tournaments_path(club))
+ expect(response.body).not_to include("btn-gated__ribbon")
+ expect(response.body).not_to include("Open Elenco")
+ expect(response.body).not_to include(public_new_club_tournament_path(club))
+
+ get public_club_tournaments_path(club)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Open Elenco")
+ expect(response.body).to include(public_new_club_tournament_path(club))
+ expect(response.body).to include(public_tournament_path(tournament))
+ end
+
+ it "crea il torneo, iscrive squadre, genera gironi e pubblica la pagina" do
+ assign_plan!("premium_full")
+ login!
+
+ expect {
+ post public_club_tournaments_path(club), params: {
+ tournament: {
+ name: "Memorial Estate",
+ sport_key: "pallavolo",
+ venue: "Palestra A",
+ starts_on: Date.current,
+ ends_on: Date.current + 1,
+ format_kind: "mixed",
+ courts: "Campo 1\nCampo 2",
+ knockout_size: 4
+ }
+ }
+ }.to change(Tournament, :count).by(1)
+
+ tournament = Tournament.order(:created_at).last
+ expect(tournament.broadcast_team).to be_present
+ expect(tournament.broadcast_team.tournament_broadcast?).to be(true)
+ expect(tournament.groups.count).to eq(2)
+ expect(response).to redirect_to(public_tournament_path(tournament))
+
+ a = tournament.groups.order(:position).first
+ b = tournament.groups.order(:position).last
+ post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Alfa", group_id: a.id } }
+ post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Beta", group_id: a.id } }
+ post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Gamma", group_id: b.id } }
+ post public_tournament_participants_path(tournament), params: { tournament_participant: { name: "Delta", group_id: b.id } }
+ expect(tournament.participants.count).to eq(4)
+
+ post public_generate_group_matches_tournament_path(tournament)
+ expect(tournament.matches.count).to be > 0
+ expect(club.teams.visible.map(&:id)).to include(team.id)
+ expect(club.teams.visible.map(&:id)).not_to include(tournament.broadcast_team_id)
+
+ match = tournament.matches.first
+ home = match.home_participant
+ away = match.away_participant
+ post public_tournament_match_result_path(tournament, match), params: { home_score: 2, away_score: 0, tab: "calendario" }
+ expect(match.reload.played?).to be(true)
+ expect(match.winner_participant_id).to eq(home.id)
+
+ standings = Tournaments::Standings.call(match.tournament_group)
+ expect(standings.first.participant).to eq(home)
+ expect(standings.first.points).to eq(3)
+
+ tournament.matches.where.associated(:tournament_group).where.not(id: match.id).find_each do |other|
+ post public_tournament_match_result_path(tournament, other),
+ params: { home_score: 2, away_score: 0, tab: "calendario" }
+ end
+ post public_propose_knockout_tournament_path(tournament)
+ final = tournament.rounds.find_by(kind: "final").matches.first
+ expect(final).to be_present
+ expect(final.home_source_kind).to eq("winner_match")
+ expect(final.away_source_kind).to eq("winner_match")
+
+ get public_tournament_path(tournament, tab: "tabellone")
+ expect(response.body).to include("Semifinali e finali")
+ expect(response.body).to include("Compila tabellone")
+ expect(response.body).to include("match[home_participant_id]")
+
+ semi = tournament.rounds.find_by(kind: "semifinal").matches.first
+ patch public_tournament_match_path(tournament, semi), params: {
+ tab: "tabellone",
+ match: {
+ home_participant_id: tournament.participants.find_by!(name: "Alfa").id,
+ away_participant_id: tournament.participants.find_by!(name: "Gamma").id
+ }
+ }
+ expect(response).to redirect_to(public_tournament_path(tournament, tab: "tabellone"))
+ expect(semi.reload.home_participant.name).to eq("Alfa")
+ expect(semi.away_participant.name).to eq("Gamma")
+
+ tournament.rounds.find_by(kind: "semifinal").matches.each do |semi|
+ post public_tournament_match_result_path(tournament, semi),
+ params: { home_score: 2, away_score: 0, tab: "tabellone" }
+ end
+ expect(final.reload.home_participant).to be_present
+ expect(final.away_participant).to be_present
+ expect(final.home_participant_id).not_to eq(final.away_participant_id)
+
+ delete public_logout_path
+ get public_tournament_page_path(tournament.slug)
+ expect(response).to have_http_status(:not_found)
+
+ login!
+ post public_publish_tournament_path(tournament)
+ get public_tournament_page_path(tournament.slug)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Memorial Estate")
+ expect(response.body).to include(home.name)
+ expect(response.body).to include("Risultati")
+ expect(response.body).to include("Tabellone")
+
+ get public_tournament_page_path(tournament.slug, tab: "tabellone")
+ expect(response.body).to include("Classifica")
+ expect(response.body).to include(home.name)
+
+ get public_tournament_pages_path
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Memorial Estate")
+ expect(response.body).to include(public_tournament_page_path(tournament.slug))
+
+ get public_live_index_path
+ expect(response.body).to include("Memorial Estate")
+ expect(response.body).to include("Segui il cartellone")
+ end
+
+ it "delega per campo/giornata e accetta l'invito" do
+ assign_plan!("premium_full")
+ login!
+ post public_club_tournaments_path(club), params: {
+ tournament: {
+ name: "Open Day",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 2"
+ }
+ }
+ tournament = Tournament.order(:created_at).last
+ home = tournament.participants.create!(name: "Casa")
+ away = tournament.participants.create!(name: "Ospite")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 2",
+ scheduled_at: Time.zone.now.change(hour: 10)
+ }
+ )
+
+ expect {
+ post public_tournament_invitations_path(tournament), params: {
+ email: "volontario@test.it",
+ note: "Porta il treppiede",
+ email_html: "
Testo personalizzato campo 2
{{link_invito}}
",
+ scope_kind: "court_day",
+ court: "Campo 2",
+ on_date: Date.current
+ }
+ }.to change { tournament.broadcast_invitations.count }.by(1)
+ .and change { ActionMailer::Base.deliveries.size }.by(1)
+
+ invitation = tournament.broadcast_invitations.last
+ expect(invitation.note).to eq("Porta il treppiede")
+ expect(invitation.email_html).to include("Testo personalizzato campo 2")
+
+ mail = ActionMailer::Base.deliveries.last
+ html = (mail.html_part || mail).body.decoded
+ expect(html).to include("Testo personalizzato campo 2")
+ expect(html).to include("/join/")
+
+ token = nil
+ follow_redirect!
+ expect(response.body).to include("/join/")
+ token = flash[:invite_url].to_s.split("/join/").last
+ token = CGI.unescapeHTML(response.body[/\/join\/([^"<\s]+)/, 1]) if token.blank?
+ expect(token).to be_present
+
+ volunteer = User.create!(email: "volontario@test.it", name: "Volo", password: "Password123", role: "volunteer")
+ invitation = tournament.broadcast_invitations.last
+ invitation.accept!(volunteer)
+ expect(volunteer.can_broadcast_match?(match)).to be(true)
+ expect(volunteer.can_broadcast_match?(Match.new(team: team))).to be(false)
+ end
+
+ it "aggiorna orario, campo e logo squadra" do
+ assign_plan!("premium_full")
+ login!
+ post public_club_tournaments_path(club), params: {
+ tournament: {
+ name: "Memorial Logo",
+ sport_key: "pallavolo",
+ venue: "Palestra A",
+ starts_on: Date.current,
+ ends_on: Date.current + 1,
+ format_kind: "groups",
+ courts: "Campo 1\nCampo 2"
+ }
+ }
+ tournament = Tournament.order(:created_at).last
+ group = tournament.groups.order(:position).first
+ alfa = tournament.participants.create!(name: "Alfa", group: group)
+ beta = tournament.participants.create!(name: "Beta", group: group)
+ post public_generate_group_matches_tournament_path(tournament)
+ match = tournament.matches.first
+
+ new_at = Time.zone.local(Date.current.year, Date.current.month, Date.current.day, 16, 30)
+ patch public_tournament_match_path(tournament, match), params: {
+ match: { court: "Campo 2", scheduled_at: new_at.strftime("%Y-%m-%dT%H:%M") }
+ }
+ match.reload
+ expect(match.court).to eq("Campo 2")
+ expect(match.scheduled_at).to eq(new_at)
+ expect(match.location).to include("Campo 2")
+
+ png = nil
+ png = Tempfile.new(["logo", ".png"])
+ png.binmode
+ png.write(Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))
+ png.rewind
+ patch public_tournament_participant_path(tournament, alfa), params: {
+ tournament_participant: {
+ name: "Alfa Blu",
+ group_id: group.id,
+ logo_file: Rack::Test::UploadedFile.new(png.path, "image/png", original_filename: "logo.png")
+ }
+ }
+ alfa.reload
+ expect(alfa.name).to eq("Alfa Blu")
+ expect(alfa.logo_file).to be_attached
+ expect(alfa.effective_logo_url).to be_present
+ if match.away_participant_id == alfa.id
+ expect(match.reload.opponent_name).to eq("Alfa Blu")
+ end
+ ensure
+ png&.close!
+ end
+
+ it "carica il logo del torneo e lo mostra in pubblico" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Open Logo",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free"
+ }
+ )
+ png = Tempfile.new(["torneo-logo", ".png"])
+ png.binmode
+ png.write(Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))
+ png.rewind
+
+ patch public_tournament_path(tournament), params: {
+ tournament: { logo_file: Rack::Test::UploadedFile.new(png.path, "image/png", original_filename: "torneo.png") }
+ }
+ expect(tournament.reload.logo_file).to be_attached
+ expect(tournament.effective_logo_url).to be_present
+
+ post public_publish_tournament_path(tournament)
+ get public_tournament_page_path(tournament.slug)
+ expect(response.body).to include("tournament-public-hero__logo")
+ expect(response.body).to include(tournament.effective_logo_url)
+
+ get public_tournament_pages_path
+ expect(response.body).to include("tournament-directory-card")
+ expect(response.body).to include(tournament.effective_logo_url)
+ ensure
+ png&.close!
+ end
+
+ it "inverte casa e ospite con un click" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Memorial Swap",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ )
+ home = tournament.participants.create!(name: "Casa")
+ away = tournament.participants.create!(name: "Ospite")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 1",
+ scheduled_at: Time.zone.now.change(hour: 11)
+ }
+ )
+ Tournaments::RecordResult.call(match: match, home_score: 2, away_score: 0, source: "manual")
+
+ get public_tournament_path(tournament, tab: "calendario")
+ expect(response.body).to include("tournament-match-swap__btn")
+ expect(response.body).to include(public_swap_tournament_match_path(tournament, match))
+ expect(response.body).to include("tournament-calendar.js")
+
+ post public_swap_tournament_match_path(tournament, match), as: :json
+ expect(response).to have_http_status(:ok)
+ expect(response).not_to be_redirect
+ json = JSON.parse(response.body)
+ expect(json["ok"]).to be(true)
+ expect(json["matchup_label"]).to include("Ospite")
+ expect(json["matchup_label"]).to include("Casa")
+
+ match.reload
+ expect(match.home_participant_id).to eq(away.id)
+ expect(match.away_participant_id).to eq(home.id)
+ expect(match.home_score).to eq(0)
+ expect(match.away_score).to eq(2)
+ expect(match.winner_participant_id).to eq(home.id)
+ expect(match.opponent_name).to eq("Casa")
+ end
+
+ it "invita e gestisce lo streaming dal piano dirette" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Memorial Streaming",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ )
+ tournament.update!(
+ invite_draft_email: "bozza@test.it",
+ invite_draft_html: "
Bozza salvata
{{link_invito}}
",
+ invite_draft_saved_at: Time.current
+ )
+ home = tournament.participants.create!(name: "Casa")
+ away = tournament.participants.create!(name: "Ospite")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 1",
+ scheduled_at: Time.zone.now.change(hour: 11)
+ }
+ )
+ other = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: away.id,
+ away_participant_id: home.id,
+ court: "Campo 1",
+ scheduled_at: Time.zone.now.change(hour: 13)
+ }
+ )
+
+ get public_tournament_path(tournament, tab: "delega")
+ expect(response).to redirect_to(public_tournament_path(tournament, tab: "dirette"))
+
+ get public_tournament_path(tournament, tab: "dirette")
+ expect(response.body).to include("Piano dirette")
+ expect(response.body).to include("Invita selezionate")
+ expect(response.body).to include("plan_match_#{match.id}")
+ expect(response.body).to include("2 da coprire")
+
+ get public_tournament_path(tournament, tab: "calendario")
+ expect(response.body).not_to include("Da coprire")
+ expect(response.body).not_to include(">Streaming<")
+
+ expect {
+ post public_tournament_invitations_path(tournament), params: {
+ email: "op.cal@test.it",
+ tab: "dirette",
+ match_ids: [match.id]
+ }
+ }.to change { tournament.broadcast_invitations.count }.by(1)
+ .and change { ActionMailer::Base.deliveries.size }.by(1)
+
+ invitation = tournament.broadcast_invitations.last
+ expect(invitation.scope_kind).to eq("matches")
+ expect(invitation.email_html).to include("Bozza salvata")
+ expect(invitation.covers_match?(match)).to be(true)
+ expect(invitation.covers_match?(other)).to be(false)
+
+ follow_redirect!
+ expect(response.body).to include("Piano dirette")
+ expect(response.body).to include("tournament-streaming-chip--pending")
+ expect(response.body).to include("tournament-live-status--waiting_operator")
+ expect(response.body).not_to include("plan_match_#{match.id}")
+ expect(response.body).to include("plan_match_#{other.id}")
+
+ volunteer = User.create!(email: "op.cal@test.it", name: "Operatore Cal", password: "Password123", role: "volunteer")
+ invitation.accept!(volunteer)
+ expect(volunteer.can_broadcast_match?(match)).to be(true)
+ expect(volunteer.can_broadcast_match?(other)).to be(false)
+
+ get public_tournament_path(tournament, tab: "dirette")
+ expect(response.body).to include("Operatore Cal")
+ expect(response.body).not_to include("tournament-streaming-chip--pending")
+
+ session = StreamSession.create!(match: match, user: volunteer, platform: "matchlivetv", status: "live")
+ get public_tournament_path(tournament, tab: "dirette")
+ expect(response.body).to include("tournament-live-status--live")
+ expect(response.body).to include("In diretta")
+
+ session.update_columns(status: "ended", ended_at: Time.current)
+ get public_tournament_path(tournament, tab: "dirette")
+ expect(response.body).to include("tournament-live-status--ended")
+
+ get public_tournament_path(tournament, tab: "calendario")
+ expect(response.body).not_to include("Operatore Cal")
+ expect(response.body).not_to include("Invita selezionate")
+
+ assignment = match.broadcast_assignments.find_by!(user: volunteer)
+ delete public_tournament_assignment_path(tournament, assignment)
+ follow_redirect!
+ expect(volunteer.reload.can_broadcast_match?(match)).to be(false)
+ expect(response.body).to include("Piano dirette")
+ expect(response.body).to include("tournament-live-status--ended")
+ expect(response.body).not_to include("Operatore Cal")
+ end
+
+ it "revoca un assegnamento senza togliere le altre gare della stessa delega" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Memorial Court Day",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 2"
+ }
+ )
+ home = tournament.participants.create!(name: "Casa")
+ away = tournament.participants.create!(name: "Ospite")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 2",
+ scheduled_at: Time.zone.now.change(hour: 10)
+ }
+ )
+ other = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: away.id,
+ away_participant_id: home.id,
+ court: "Campo 2",
+ scheduled_at: Time.zone.now.change(hour: 12)
+ }
+ )
+ post public_tournament_invitations_path(tournament), params: {
+ email: "campo@test.it",
+ scope_kind: "court_day",
+ court: "Campo 2",
+ on_date: Date.current
+ }
+ volunteer = User.create!(email: "campo@test.it", name: "Campo Op", password: "Password123", role: "volunteer")
+ invitation = tournament.broadcast_invitations.last
+ invitation.accept!(volunteer)
+ expect(volunteer.can_broadcast_match?(match)).to be(true)
+ expect(volunteer.can_broadcast_match?(other)).to be(true)
+
+ assignment = match.broadcast_assignments.find_by!(user: volunteer)
+ delete public_tournament_assignment_path(tournament, assignment)
+ expect(volunteer.reload.can_broadcast_match?(match)).to be(false)
+ expect(volunteer.can_broadcast_match?(other)).to be(true)
+ expect(invitation.reload.scope_kind).to eq("matches")
+ expect(invitation.match_ids.map(&:to_s)).to contain_exactly(other.id.to_s)
+ end
+
+ it "carica un'immagine per la mail di delega" do
+ assign_plan!("premium_full")
+ login!
+ post public_club_tournaments_path(club), params: {
+ tournament: {
+ name: "Memorial Mail",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ }
+ tournament = Tournament.order(:created_at).last
+ png = Tempfile.new(["invite", ".png"])
+ png.binmode
+ png.write(Base64.decode64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="))
+ png.rewind
+ post public_tournament_invite_images_path(tournament), params: {
+ file: Rack::Test::UploadedFile.new(png.path, "image/png", original_filename: "logo.png")
+ }
+ expect(response).to have_http_status(:ok)
+ json = JSON.parse(response.body)
+ expect(json["url"]).to include("/rails/active_storage")
+ expect(tournament.reload.invite_email_images).to be_attached
+ ensure
+ png&.close!
+ end
+
+ it "salva la bozza della mail senza inviarla" do
+ assign_plan!("premium_full")
+ login!
+ post public_club_tournaments_path(club), params: {
+ tournament: {
+ name: "Memorial Bozza",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ }
+ tournament = Tournament.order(:created_at).last
+
+ expect {
+ post public_tournament_invite_draft_path(tournament), params: {
+ email: "dopo@test.it",
+ note: "Porta il treppiede",
+ email_html: %(
Testo bozza con {{link_invito}}

)
+ }
+ }.not_to change { ActionMailer::Base.deliveries.size }
+
+ tournament.reload
+ expect(tournament.invite_draft_note).to eq("Porta il treppiede")
+ expect(tournament.invite_draft_email).to eq("dopo@test.it")
+ expect(tournament.invite_draft_html).to include("Testo bozza")
+ expect(tournament.invite_draft_html).to include("{{link_invito}}")
+ expect(tournament.invite_draft_html).to include("logo.png")
+ expect(tournament.invite_draft_saved_at).to be_present
+
+ get public_tournament_path(tournament, tab: "dirette")
+ expect(response.body).to include("Testo bozza")
+ expect(response.body).to include("Porta il treppiede")
+ expect(response.body).to include("dopo@test.it")
+ expect(response.body).to include("Salva bozza")
+ expect(response.body).to include("Piano dirette")
+ expect(response.body).to include("Testo della mail")
+ expect(response.body).to include("Invita un operatore")
+ end
+
+ it "elimina il torneo, le partite e il team interno" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Memorial da cancellare",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ )
+ home = tournament.participants.create!(name: "Casa")
+ away = tournament.participants.create!(name: "Ospite")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 1",
+ scheduled_at: Time.zone.now.change(hour: 10)
+ }
+ )
+ session = StreamSession.create!(
+ match: match, user: coach, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago
+ )
+ Recording.create!(
+ stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public",
+ title: "Replay torneo",
+ storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4"
+ )
+ team_id = tournament.broadcast_team_id
+ match_id = match.id
+
+ get public_tournament_path(tournament)
+ expect(response.body).to include("Elimina")
+
+ expect {
+ delete public_tournament_path(tournament)
+ }.to change(Tournament, :count).by(-1)
+ .and change(Match, :count).by(-1)
+ .and change(Team, :count).by(-1)
+
+ expect(response).to redirect_to(public_club_tournaments_path(club))
+ expect(Tournament.find_by(id: tournament.id)).to be_nil
+ expect(Match.find_by(id: match_id)).to be_nil
+ expect(Team.find_by(id: team_id)).to be_nil
+ expect(Recording.find_by(stream_session_id: session.id)).to be_nil
+
+ get public_tournament_path(tournament.id)
+ expect(response).to have_http_status(:not_found)
+ end
+
+ it "non elimina il torneo se una diretta è in corso" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Memorial live",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ )
+ home = tournament.participants.create!(name: "Casa")
+ away = tournament.participants.create!(name: "Ospite")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 1",
+ scheduled_at: Time.zone.now.change(hour: 18)
+ }
+ )
+ StreamSession.create!(match: match, user: coach, platform: "matchlivetv", status: "live")
+
+ expect {
+ delete public_tournament_path(tournament)
+ }.not_to change(Tournament, :count)
+ expect(response).to redirect_to(public_tournament_path(tournament))
+ follow_redirect!
+ expect(response.body).to include("Chiudi le dirette")
+ end
+
+ it "l'owner elimina il torneo anche senza Premium Full" do
+ assign_plan!("premium_full")
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Memorial declassato",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current,
+ format_kind: "free"
+ }
+ )
+ assign_plan!("premium_light")
+ login!
+
+ expect {
+ delete public_tournament_path(tournament)
+ }.to change(Tournament, :count).by(-1)
+ expect(response).to redirect_to(public_club_tournaments_path(club))
+ end
+
+ it "mostra sul cartellone i link a diretta e replay" do
+ assign_plan!("premium_full")
+ login!
+ tournament = Tournaments::Create.call(
+ club: club,
+ attrs: {
+ name: "Open Cartellone",
+ sport_key: "pallavolo",
+ starts_on: Date.current,
+ ends_on: Date.current + 1,
+ format_kind: "free",
+ courts: "Campo 1"
+ }
+ )
+ home = tournament.participants.create!(name: "Alfa")
+ away = tournament.participants.create!(name: "Beta")
+ match = Tournaments::ScheduleMatch.call(
+ tournament: tournament,
+ attrs: {
+ home_participant_id: home.id,
+ away_participant_id: away.id,
+ court: "Campo 1",
+ scheduled_at: 2.hours.from_now
+ }
+ )
+ post public_publish_tournament_path(tournament)
+
+ session = StreamSession.create!(
+ match: match, user: coach, platform: "matchlivetv", status: "live", privacy_status: "public"
+ )
+ get public_tournament_page_path(tournament.slug)
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Risultati")
+ expect(response.body).to include("Guarda diretta")
+ expect(response.body).to include(public_live_path(session))
+ expect(response.body).to include("tournament-live-status--live")
+
+ session.update_columns(status: "ended", ended_at: Time.current)
+ Recording.create!(
+ stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public",
+ title: "Replay Alfa-Beta",
+ storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4"
+ )
+
+ get public_tournament_page_path(tournament.slug)
+ expect(response.body).to include("Guarda replay")
+ expect(response.body).to include(public_replay_path(session.id))
+ expect(response.body).to include("tournament-live-status--replay")
+ expect(response.body).to include("Replay Alfa-Beta")
+
+ get public_club_tournaments_path(club)
+ expect(response.body).to include(public_tournament_page_path(tournament.slug))
+ end
+end
diff --git a/backend/spec/services/recordings/cleanup_local_spec.rb b/backend/spec/services/recordings/cleanup_local_spec.rb
index 9252520..5dc3025 100644
--- a/backend/spec/services/recordings/cleanup_local_spec.rb
+++ b/backend/spec/services/recordings/cleanup_local_spec.rb
@@ -35,6 +35,8 @@ RSpec.describe Recordings::CleanupLocal do
stream_session: session, team: team, status: "ready", privacy_status: "public",
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4"
)
+ mtx = instance_double(Mediamtx::Client, delete_path: true)
+ allow(Mediamtx::Client).to receive(:for_session).and_return(mtx)
result = described_class.new(online_paths: []).call
diff --git a/backend/spec/services/tournaments/compose_invite_email_spec.rb b/backend/spec/services/tournaments/compose_invite_email_spec.rb
new file mode 100644
index 0000000..72b9f91
--- /dev/null
+++ b/backend/spec/services/tournaments/compose_invite_email_spec.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe Tournaments::ComposeInviteEmail do
+ it "sostituisce i token e rimuove HTML pericoloso" do
+ html = <<~HTML
+
Ciao custom
+
+
x
+
{{link_invito}}
+
Scade il {{scadenza}}
+
)
+

+ HTML
+
+ out = described_class.call(
+ html: html,
+ invite_url: "https://www.example.com/join/token-ok",
+ expires_on: "10 settembre 2026"
+ )
+
+ expect(out).to include("Ciao custom")
+ expect(out).to include("https://www.example.com/join/token-ok")
+ expect(out).to include("10 settembre 2026")
+ expect(out).to include("/rails/active_storage/blobs/redirect/abc/logo.png")
+ expect(out).not_to include("