diff --git a/backend/app/controllers/api/v1/recordings_controller.rb b/backend/app/controllers/api/v1/recordings_controller.rb index f673978..f3da572 100644 --- a/backend/app/controllers/api/v1/recordings_controller.rb +++ b/backend/app/controllers/api/v1/recordings_controller.rb @@ -14,7 +14,9 @@ module Api def destroy authorize_manage! - Recordings::Delete.new(@recording).call + scope = params[:delete_scope].to_s + scope = "both" unless Recordings::Delete::SCOPES.include?(scope) + Recordings::Delete.new(@recording, scope: scope).call head :no_content end diff --git a/backend/app/controllers/concerns/recordings/club_archive_actions.rb b/backend/app/controllers/concerns/recordings/club_archive_actions.rb index 2f52ca2..15d4001 100644 --- a/backend/app/controllers/concerns/recordings/club_archive_actions.rb +++ b/backend/app/controllers/concerns/recordings/club_archive_actions.rb @@ -35,8 +35,9 @@ module Recordings end def destroy - Recordings::Delete.new(@recording).call - redirect_to archive_index_path, notice: "Replay eliminato" + scope = delete_scope_param + Recordings::Delete.new(@recording, scope: scope).call + redirect_to archive_index_path, notice: delete_notice_for(scope) end def publish_youtube @@ -99,6 +100,22 @@ module Recordings params.require(:recording).permit(:privacy_status, :title, :expires_at, :extend_days) end + def delete_scope_param + value = params[:delete_scope].to_s + Recordings::Delete::SCOPES.include?(value) ? value : "both" + end + + def delete_notice_for(scope) + case scope + when "site" + "Replay eliminato dal sito (YouTube invariato)" + when "youtube" + "Video rimosso da YouTube (resta disponibile sul sito)" + else + "Replay eliminato dal sito e da YouTube" + end + end + def admin_expiry_update_requested? return false unless archive_namespace == :admin diff --git a/backend/app/controllers/public/locales_controller.rb b/backend/app/controllers/public/locales_controller.rb new file mode 100644 index 0000000..889c70a --- /dev/null +++ b/backend/app/controllers/public/locales_controller.rb @@ -0,0 +1,9 @@ +module Public + class LocalesController < SiteBaseController + def update + locale = LocaleResolver.persist!(cookies, params[:locale]) + I18n.locale = locale + redirect_back fallback_location: root_path + end + end +end diff --git a/backend/app/controllers/public/site_base_controller.rb b/backend/app/controllers/public/site_base_controller.rb index e6f5b26..57e22d0 100644 --- a/backend/app/controllers/public/site_base_controller.rb +++ b/backend/app/controllers/public/site_base_controller.rb @@ -5,14 +5,30 @@ module Public include ::SeoHelper include ::LegalHelper helper ApplicationHelper - helper_method :current_user, :logged_in? + helper_method :current_user, :logged_in?, :current_locale, :language_options, :current_language_option + protect_from_forgery with: :exception before_action :set_site_locale private def set_site_locale - I18n.locale = :it + I18n.locale = LocaleResolver.resolve( + cookie_jar: request.cookie_jar, + accept_language: request.get_header("HTTP_ACCEPT_LANGUAGE") + ) + end + + def current_locale + I18n.locale + end + + def language_options + LocaleResolver.language_options + end + + def current_language_option + LocaleResolver.current_language_option end def current_user diff --git a/backend/app/controllers/public/sitemap_controller.rb b/backend/app/controllers/public/sitemap_controller.rb index e547e92..814a237 100644 --- a/backend/app/controllers/public/sitemap_controller.rb +++ b/backend/app/controllers/public/sitemap_controller.rb @@ -9,11 +9,20 @@ module Public { loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" }, { loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" }, { loc: "#{base}/live", changefreq: "hourly", priority: "0.85" }, + { loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" }, { loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" } ] + Team.find_each do |team| + @entries << { + loc: "#{base}/squadre/#{team.slug}", + changefreq: "daily", + priority: "0.7" + } + end + respond_to do |format| format.xml { render layout: false } end diff --git a/backend/app/controllers/public/team_pages_controller.rb b/backend/app/controllers/public/team_pages_controller.rb new file mode 100644 index 0000000..c899ee5 --- /dev/null +++ b/backend/app/controllers/public/team_pages_controller.rb @@ -0,0 +1,64 @@ +module Public + class TeamPagesController < SiteBaseController + include Public::LiveHelper + + layout "marketing_live" + + def index + @query = params[:q].to_s.strip + @sport = params[:sport].to_s.strip.presence + @live_filter = params[:live].present? + @replays_filter = params[:replays].present? + @entries = Teams::PublicDirectory.call( + q: @query, + sport: @sport, + live: @live_filter, + replays: @replays_filter + ) + @sport_options = Sports::Catalog.as_api_list + end + + def show + @team = Team.includes(:club, roster_members: []).find_by!(slug: params[:slug]) + @club = @team.club + load_live_content! + end + + private + + def load_live_content! + @online_paths = fetch_online_paths + + @live_sessions = StreamSession + .broadcasting + .publicly_listed + .where(platform: "matchlivetv") + .includes(:score_state, match: { team: :club }) + .joins(:match) + .where(matches: { team_id: @team.id }) + .order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc) + + broadcasting_match_ids = StreamSession.broadcasting.select(:match_id) + @upcoming_matches = @team.matches + .scheduled_for_live + .where.not(id: broadcasting_match_ids) + .order(scheduled_at: :asc) + .limit(20) + + @recordings = @team.recordings + .ready + .publicly_listed + .includes(stream_session: :match) + .order(recorded_at: :desc, created_at: :desc) + .limit(12) + + @roster_by_category = @team.roster_public? ? @team.roster_by_category : nil + end + + def fetch_online_paths + Mediamtx::Client.new.online_path_names + rescue Mediamtx::Client::Error, Errno::ECONNREFUSED, SocketError + [] + end + end +end diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 474424a..bb2ee0f 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -165,7 +165,7 @@ module Public end def team_params - p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color) + p = params.require(:team).permit(:name, :sport, :sport_key, :description, :logo_url, :primary_color, :secondary_color, :slug, :roster_public) if p[:sport].present? && p[:sport_key].blank? p[:sport_key] = p.delete(:sport) elsif p[:sport_key].present? diff --git a/backend/app/helpers/application_helper.rb b/backend/app/helpers/application_helper.rb index 201bda6..02e7150 100644 --- a/backend/app/helpers/application_helper.rb +++ b/backend/app/helpers/application_helper.rb @@ -9,12 +9,26 @@ module ApplicationHelper PLAN_ICONS[plan.slug] || "fa-solid fa-circle" end - # Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito. + # Data/ora nel fuso dell'app (Europe/Rome) e nella lingua corrente. def l_local(date_or_time, format: :long) return nil if date_or_time.blank? value = date_or_time.respond_to?(:in_time_zone) ? date_or_time.in_time_zone : date_or_time - I18n.with_locale(:it) { I18n.l(value, format: format) } + I18n.l(value, format: format) + end + + def og_locale_tag + { + it: "it_IT", + en: "en_US", + fr: "fr_FR", + de: "de_DE", + es: "es_ES" + }.fetch(I18n.locale.to_sym, "it_IT") + end + + def html_lang + I18n.locale.to_s end def sport_catalog_options(selected = nil) diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index e6e043e..dc60bf7 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -50,13 +50,18 @@ module Public end end - def live_match_card_heading(match) + def live_match_card_heading(match, link_team: true) team = match.team club_name = team.club&.name.presence || "Società" + team_label = if link_team && team.slug.present? + link_to(team.name, public_team_page_path(team.slug), class: "live-card__team-link") + else + team.name + end content_tag(:h3, class: "live-card__title") do safe_join([ content_tag(:span, club_name, class: "live-card__club"), - content_tag(:span, "#{team.name} vs #{match.opponent_name}", class: "live-card__matchup") + content_tag(:span, safe_join([team_label, " vs ", match.opponent_name]), class: "live-card__matchup") ]) end end diff --git a/backend/app/helpers/public/team_pages_helper.rb b/backend/app/helpers/public/team_pages_helper.rb new file mode 100644 index 0000000..a636c29 --- /dev/null +++ b/backend/app/helpers/public/team_pages_helper.rb @@ -0,0 +1,25 @@ +module Public + module TeamPagesHelper + def team_page_meta_description(team, club) + "Segui #{team.name} (#{club.name}): dirette live, calendario partite e replay su Match Live TV." + end + + def filter_params_for_canonical + params.permit(:q, :sport, :live, :replays).to_h.compact_blank + end + + def team_page_structured_data(team, club) + { + "@context" => "https://schema.org", + "@type" => "SportsTeam", + "name" => team.name, + "sport" => team.sport_label, + "url" => seo_absolute_url(public_team_page_path(team.slug)), + "memberOf" => { + "@type" => "SportsOrganization", + "name" => club.name + } + } + end + end +end diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 335af05..072d749 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -13,9 +13,14 @@ class Team < ApplicationRecord validates :name, presence: true validates :sport_key, presence: true + validates :slug, presence: true, uniqueness: true, + format: { with: /\A[a-z0-9]+(?:-[a-z0-9]+)*\z/, message: "solo lettere minuscole, numeri e trattini" } validate :sport_key_known validate :photo_file_type, if: -> { photo_file.attached? } + before_validation :assign_slug, on: :create + before_validation :normalize_slug, if: -> { slug_changed? && slug.present? } + before_validation :normalize_sport_key def subscription @@ -47,6 +52,14 @@ class Team < ApplicationRecord end end + def roster_public? + roster_public + end + + def public_page_path + Rails.application.routes.url_helpers.public_team_page_path(slug) + end + def sport_label Sports::Catalog.find_optional(sport_key)&.dig(:label) || sport_key.to_s.humanize end @@ -86,6 +99,14 @@ class Team < ApplicationRecord club end + def assign_slug + self.slug = Teams::GenerateSlug.call(self) if slug.blank? + end + + def normalize_slug + self.slug = slug.to_s.parameterize + end + def default_primary_color club&.primary_color.presence || super end diff --git a/backend/app/services/locale_resolver.rb b/backend/app/services/locale_resolver.rb new file mode 100644 index 0000000..efc98ed --- /dev/null +++ b/backend/app/services/locale_resolver.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module LocaleResolver + COOKIE_NAME = "mltv_locale" + COOKIE_MAX_AGE = 2.years.to_i + + module_function + + def available + I18n.available_locales.map(&:to_sym) + end + + def normalize(value) + code = value.to_s.strip.downcase.split(/[-_]/).first + return nil if code.blank? + + sym = code.to_sym + available.include?(sym) ? sym : nil + end + + def from_cookie(cookies) + return nil if cookies.nil? + + value = if cookies.respond_to?(:[]) + cookies[COOKIE_NAME] + elsif cookies.respond_to?(:fetch) + cookies.fetch(COOKIE_NAME, nil) + end + normalize(value) + end + + def from_accept_language(header) + return nil if header.blank? + + header.to_s.split(",").each do |part| + tag = part.split(";").first.to_s.strip + locale = normalize(tag) + return locale if locale + end + nil + end + + def resolve(cookie_jar: nil, cookies: nil, accept_language: nil) + jar = cookie_jar || cookies + from_cookie(jar) || from_accept_language(accept_language) || I18n.default_locale + end + + def persist!(cookie_jar, locale) + normalized = normalize(locale) || I18n.default_locale + cookie_jar[COOKIE_NAME] = { + value: normalized.to_s, + expires: COOKIE_MAX_AGE.seconds.from_now, + path: "/", + same_site: :lax, + httponly: false + } + normalized + end + + def language_options + [ + { code: :it, label: "Italiano", native: "Italiano", flag: "🇮🇹" }, + { code: :en, label: "English", native: "English", flag: "🇬🇧" }, + { code: :fr, label: "Français", native: "Français", flag: "🇫🇷" }, + { code: :de, label: "Deutsch", native: "Deutsch", flag: "🇩🇪" }, + { code: :es, label: "Español", native: "Español", flag: "🇪🇸" } + ].select { |opt| available.include?(opt[:code]) } + end + + def current_language_option + language_options.find { |opt| opt[:code] == I18n.locale.to_sym } || language_options.first + end +end diff --git a/backend/app/services/recordings/delete.rb b/backend/app/services/recordings/delete.rb index e31a4db..7b71201 100644 --- a/backend/app/services/recordings/delete.rb +++ b/backend/app/services/recordings/delete.rb @@ -1,11 +1,33 @@ module Recordings class Delete - def initialize(recording, reason: :manual) + SCOPES = %w[both site youtube].freeze + + def initialize(recording, reason: :manual, scope: :both) @recording = recording @reason = reason + @scope = normalize_scope(scope) end def call + case @scope + when "youtube" + delete_youtube_only! + when "site" + delete_site_only! + else + delete_both! + end + @recording + end + + private + + def normalize_scope(scope) + value = scope.to_s.presence || "both" + SCOPES.include?(value) ? value : "both" + end + + def delete_both! delete_youtube_video delete_storage_object if @recording.storage_key.present? cleanup_local_artifacts @@ -18,10 +40,27 @@ module Recordings youtube_video_id: nil, youtube_published_at: nil ) - @recording end - private + def delete_site_only! + delete_storage_object if @recording.storage_key.present? + cleanup_local_artifacts + + @recording.update!( + status: "expired", + deleted_at: Time.current, + storage_key: nil, + thumbnail_storage_key: nil + ) + end + + def delete_youtube_only! + delete_youtube_video + @recording.update!( + youtube_video_id: nil, + youtube_published_at: nil + ) + end def delete_youtube_video return if @recording.youtube_video_id.blank? diff --git a/backend/app/services/teams/generate_slug.rb b/backend/app/services/teams/generate_slug.rb new file mode 100644 index 0000000..db24f75 --- /dev/null +++ b/backend/app/services/teams/generate_slug.rb @@ -0,0 +1,30 @@ +module Teams + class GenerateSlug + def self.call(team) + new(team).call + end + + def initialize(team) + @team = team + end + + def call + base = @team.name.to_s.parameterize.presence || "squadra" + slug = base + n = 2 + while conflict?(slug) + slug = "#{base}-#{n}" + n += 1 + end + slug + end + + private + + def conflict?(slug) + scope = Team.where(slug: slug) + scope = scope.where.not(id: @team.id) if @team.persisted? + scope.exists? + end + end +end diff --git a/backend/app/services/teams/public_directory.rb b/backend/app/services/teams/public_directory.rb new file mode 100644 index 0000000..0273bac --- /dev/null +++ b/backend/app/services/teams/public_directory.rb @@ -0,0 +1,144 @@ +module Teams + # Elenco pubblico delle squadre con presenza su Match Live TV (diretta, replay o calendario). + class PublicDirectory + Entry = Struct.new( + :team, + :live_now, + :replay_count, + :upcoming_count, + :last_activity_at, + keyword_init: true + ) + + def self.call(q: nil, sport: nil, live: false, replays: false) + new(q: q, sport: sport, live: live, replays: replays).call + end + + def initialize(q: nil, sport: nil, live: false, replays: false) + @q = q.to_s.strip + @sport = normalize_sport(sport) + @live = cast_bool(live) + @replays = cast_bool(replays) + end + + def call + teams = base_teams + return [] if teams.empty? + + entries = build_entries(teams) + entries = apply_toggle_filters(entries) + sort_entries(entries) + end + + private + + def normalize_sport(value) + key = value.to_s.strip + return nil if key.blank? + return nil unless Sports::Catalog.find_optional(key) + + Sports::Catalog.normalize_key(key) + end + + def cast_bool(value) + ActiveModel::Type::Boolean.new.cast(value) + end + + def base_teams + scope = Team.includes(:club).where(id: active_team_ids) + scope = scope.where(sport_key: @sport) if @sport + scope = apply_search(scope) if @q.present? + scope.to_a + end + + def active_team_ids + live = live_team_ids + replays = replay_team_ids + upcoming = upcoming_team_ids + (live + replays + upcoming).uniq + end + + def live_team_ids + StreamSession.broadcasting + .publicly_listed + .where(platform: "matchlivetv") + .joins(:match) + .distinct + .pluck("matches.team_id") + end + + def replay_team_ids + Recording.ready.publicly_listed.joins(stream_session: :match).distinct.pluck("matches.team_id") + end + + def upcoming_team_ids + broadcasting_match_ids = StreamSession.broadcasting.select(:match_id) + Match.scheduled_for_live + .where.not(id: broadcasting_match_ids) + .distinct + .pluck(:team_id) + end + + def apply_search(scope) + term = "%#{ActiveRecord::Base.sanitize_sql_like(@q)}%" + scope.left_joins(:club, :matches).where( + "teams.name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term OR matches.opponent_name ILIKE :term", + term: term + ).distinct + end + + def build_entries(teams) + team_ids = teams.map(&:id) + live_set = live_team_ids.to_set + replay_counts = Recording.ready.publicly_listed + .joins(stream_session: :match) + .where(matches: { team_id: team_ids }) + .group("matches.team_id") + .count + last_replay_at = Recording.ready.publicly_listed + .joins(stream_session: :match) + .where(matches: { team_id: team_ids }) + .group("matches.team_id") + .maximum(:recorded_at) + last_live_at = StreamSession.broadcasting + .publicly_listed + .joins(:match) + .where(matches: { team_id: team_ids }) + .group("matches.team_id") + .maximum(:started_at) + broadcasting_match_ids = StreamSession.broadcasting.select(:match_id) + upcoming_counts = Match.scheduled_for_live + .where(team_id: team_ids) + .where.not(id: broadcasting_match_ids) + .group(:team_id) + .count + + teams.map do |team| + last_activity = [last_replay_at[team.id], last_live_at[team.id]].compact.max + Entry.new( + team: team, + live_now: live_set.include?(team.id), + replay_count: replay_counts[team.id].to_i, + upcoming_count: upcoming_counts[team.id].to_i, + last_activity_at: last_activity + ) + end + end + + def apply_toggle_filters(entries) + entries = entries.select(&:live_now) if @live + entries = entries.select { |entry| entry.replay_count.positive? } if @replays + entries + end + + def sort_entries(entries) + entries.sort_by do |entry| + [ + entry.live_now ? 0 : 1, + -(entry.last_activity_at&.to_i || 0), + entry.team.name.downcase + ] + end + end + end +end diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index cbfcdd9..8ccc37e 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -6,12 +6,14 @@ <% if content_for?(:replay_archive_styles) %> - + <% end %> <% if controller_name == "dashboard" %> <% end %> + +
diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index 65ce173..6a11a13 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -1,5 +1,5 @@ - + @@ -8,7 +8,7 @@ <%= render "shared/meta_tags" %> <%= yield :head %> - + data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <%= render "shared/cookie_banner" %> @@ -22,6 +22,8 @@ + + diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index 2d1eeb4..506cfa4 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -1,12 +1,12 @@ - + <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= yield :head %> @@ -17,6 +17,8 @@ <%= yield %> <%= render "shared/marketing_footer" %> + + diff --git a/backend/app/views/layouts/public.html.erb b/backend/app/views/layouts/public.html.erb index 6d291bf..3efb56b 100644 --- a/backend/app/views/layouts/public.html.erb +++ b/backend/app/views/layouts/public.html.erb @@ -37,9 +37,9 @@ <%= link_to "Prezzi", public_pricing_path %> <% if logged_in? %> <% if current_user.primary_club %> - · <%= link_to "Società", public_club_path(current_user.primary_club) %> + · <%= link_to "La mia società", public_club_path(current_user.primary_club) %> <% elsif current_user.manageable_teams.any? %> - · <%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first) %> + · <%= link_to "La mia squadra", public_team_details_path(current_user.manageable_teams.first) %> <% end %> · <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %> <% else %> diff --git a/backend/app/views/public/clubs/show.html.erb b/backend/app/views/public/clubs/show.html.erb index c548826..3994b83 100644 --- a/backend/app/views/public/clubs/show.html.erb +++ b/backend/app/views/public/clubs/show.html.erb @@ -74,6 +74,8 @@ <% end %> + <%= link_to "Pagina pubblica", public_team_page_path(team.slug), target: "_blank", rel: "noopener" %> + · <%= link_to "Dettagli", public_team_details_path(team) %> · <%= link_to "Partite", public_team_matches_path(team) %> diff --git a/backend/app/views/public/pages/home.html.erb b/backend/app/views/public/pages/home.html.erb index 18f6bab..920ca3d 100644 --- a/backend/app/views/public/pages/home.html.erb +++ b/backend/app/views/public/pages/home.html.erb @@ -1,5 +1,5 @@ -<% content_for :title, "Match Live TV — Diretta live partite giovanili da telefono" %> -<% content_for :meta_description, "Non puoi andare in palestra? Guarda la diretta live della partita di tuo figlio dal telefono o dal computer, senza installare app. Pallavolo, calcio e sport giovanili: link da condividere con nonni e parenti, archivio se te la perdi." %> +<% content_for :title, t("home.title") %> +<% content_for :meta_description, t("home.meta_description") %> <% content_for :canonical_url, seo_absolute_url(root_path) %> <% content_for :head do %> <% end %> @@ -20,104 +20,94 @@

Match Live TV

LIVE

-

Ogni partita, ogni evento, per chi non può esserci.

-

- Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — - guardano dal browser, senza installare nulla. - E se te la sei persa, la ritrovi nell'archivio. -

+

<%= raw t("home.headline_html") %>

+

<%= t("home.tagline") %>

- <%= link_to "Registra la tua squadra", public_signup_path, class: "btn btn-primary" %> - <%= link_to "Guarda le dirette", public_live_index_path, class: "btn btn-secondary" %> + <%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %> + <%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
-
    +
    • - Dirette stabili + <%= t("home.feature_stable") %>
    • - Archivio sicuro + <%= t("home.feature_archive") %>
    • - Condividi con chi vuoi + <%= t("home.feature_share") %>
    • - Tutto dal tuo telefono + <%= t("home.feature_phone") %>
- <%= image_tag "/hero-devices.png", alt: "Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra", class: "hero-devices-img", loading: "eager", fetchpriority: "high" %> + <%= image_tag "/hero-devices.png", alt: t("home.hero_alt"), class: "hero-devices-img", loading: "eager", fetchpriority: "high" %>
-

Come funziona lo streaming per squadre giovanili

+

<%= t("home.how_title") %>

- <%= image_tag "/home-step-registra-squadra.png?v=1", alt: "Registrazione squadra: scudo, pallone e modulo con email, password e nome squadra", class: "step-visual-img", loading: "lazy" %> + <%= image_tag "/home-step-registra-squadra.png?v=1", alt: t("home.step1_title"), class: "step-visual-img", loading: "lazy" %>
01
-

Registra la squadra

-

La società si iscrive sul sito e sceglie il piano più adatto.

+

<%= t("home.step1_title") %>

+

<%= t("home.step1_body") %>

- <%= image_tag "/home-step-invita-trasmette.png?v=1", alt: "Invito a trasmettere: smartphone con invio email e busta con accetta invito", class: "step-visual-img", loading: "lazy" %> + <%= image_tag "/home-step-invita-trasmette.png?v=1", alt: t("home.step2_title"), class: "step-visual-img", loading: "lazy" %>
02
-

Invita chi trasmette

-

Coach e volontari ricevono un invito: ognuno accede con la propria email.

+

<%= t("home.step2_title") %>

+

<%= t("home.step2_body") %>

- <%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: "Vai in diretta: smartphone su treppiede, punteggio live e condivisione link", class: "step-visual-img", loading: "lazy" %> + <%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: t("home.step3_title"), class: "step-visual-img", loading: "lazy" %>
03
-

Vai in diretta

-

Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie.

+

<%= t("home.step3_title") %>

+

<%= t("home.step3_body") %>

- <%= image_tag "/home-step-tutti-guardano.png?v=2", alt: "Tutti guardano da casa: archivio partite, partita salvata e link per genitori e parenti", class: "step-visual-img", loading: "lazy" %> + <%= image_tag "/home-step-tutti-guardano.png?v=2", alt: t("home.step4_title"), class: "step-visual-img", loading: "lazy" %>
04
-

Tutti guardano da casa

-

Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.

+

<%= t("home.step4_title") %>

+

<%= t("home.step4_body") %>

-

Piani per dirette live e archivio partite

-

Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.

+

<%= t("home.plans_title") %>

+

<%= t("home.plans_lead") %>

- <%= image_tag "/home-piani-ecosistema.png?v=1", alt: "Ecosistema Match Live TV: smartphone in diretta, laptop con live e statistiche, tablet con archivio partite e cloud", class: "plans-teaser-img", loading: "lazy" %> + <%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
- <%= link_to "Confronta i piani", public_prezzi_path, class: "btn btn-primary" %> + <%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
-

Streaming partite giovanili: perché le famiglie scelgono Match Live TV

+

<%= t("home.seo_title") %>

+

<%= raw t("home.seo_p1_html") %>

+

<%= raw t("home.seo_p2_html") %>

- Allenatori e dirigenti cercano un modo semplice per mandare in diretta live le partite - di pallavolo giovanile, calcio, basket e altri sport: senza attrezzature da TV, - solo con lo smartphone in palestra. Match Live TV è pensato per le società dilettantistiche che vogliono - far guardare Under 14, Under 16, Under 18 e settori giovanili a chi è lontano. -

-

- Un genitore che lavora, un nonno che non può viaggiare, un parente all’estero: aprono un link e seguono - la partita dal browser. Se arrivano in ritardo, la registrazione in archivio (con i piani Premium) - resta disponibile per giorni. Niente account complicati per chi guarda — solo per lo staff che trasmette. -

-

- Hai dubbi su come funziona? Leggi le <%= link_to "domande frequenti", public_faq_path %>, - scopri <%= link_to "Match Live TV per la pallavolo giovanile", public_pallavolo_path %> - o <%= link_to "registra la squadra gratis", public_signup_path %>. + <%= raw t( + "home.seo_p3_html", + faq_link: link_to(t("home.seo_faq_link"), public_faq_path), + volleyball_link: link_to(t("home.seo_volleyball_link"), public_pallavolo_path), + signup_link: link_to(t("home.seo_signup_link"), public_signup_path) + ) %>

diff --git a/backend/app/views/public/team_pages/index.html.erb b/backend/app/views/public/team_pages/index.html.erb new file mode 100644 index 0000000..e8c117f --- /dev/null +++ b/backend/app/views/public/team_pages/index.html.erb @@ -0,0 +1,129 @@ +<% content_for :title, "Squadre su Match Live TV — Dirette e replay sport giovanili" %> +<% content_for :meta_description, "Scopri le squadre sportive giovanili che trasmettono su Match Live TV. Cerca per nome, filtra per sport, trova dirette in corso e archivi replay." %> +<% content_for :canonical_url, seo_absolute_url(public_team_pages_path(filter_params_for_canonical)) %> + +
+

Squadre su Match Live TV

+

+ Società e squadre con dirette, replay pubblici o partite in programma. + Cerca la tua squadra e apri la pagina per seguire calendario e archivio. +

+ + <%= form_with url: public_team_pages_path, method: :get, local: true, class: "team-directory-filters" do %> + + +
+
+ + +
+ +
+ Mostra +
+ + +
+
+ + <% if @query.present? || @sport.present? || @live_filter || @replays_filter %> +
+ + <%= link_to "Azzera", public_team_pages_path, class: "btn btn-secondary team-directory-filters__reset" %> +
+ <% end %> +
+ <% end %> + + <% if @query.present? || @sport.present? || @live_filter || @replays_filter %> +

+ <%= @entries.size %> squadre trovate + <% if @query.present? %> per «<%= h @query %>»<% end %> + <% if @sport.present? %> · <%= Sports::Catalog.find_optional(@sport)&.dig(:label) || @sport %><% end %> +

+ <% else %> +

<%= @entries.size %> squadre attive

+ <% end %> + + <% if @entries.any? %> +
+ <% @entries.each do |entry| %> + <% team = entry.team %> + <% club = team.club %> + <% logo = team.effective_logo_url.presence || club.effective_logo_url %> + <%= link_to public_team_page_path(team.slug), class: "team-directory-card" do %> +
+ <% if team.team_photo_url.present? %> + <%= image_tag team.team_photo_url, alt: "", class: "team-directory-card__photo", loading: "lazy" %> + <% elsif logo.present? %> + <%= image_tag logo, alt: "", class: "team-directory-card__photo team-directory-card__photo--logo", loading: "lazy" %> + <% else %> + + <% end %> +
+
+

<%= club.name %>

+

<%= team.name %>

+

<%= team.sport_label %>

+
+ <% if entry.live_now %> + In diretta + <% end %> + <% if entry.upcoming_count.positive? %> + <%= entry.upcoming_count %> in programma + <% end %> + <% if entry.replay_count.positive? %> + <%= entry.replay_count %> replay + <% end %> +
+
+ <% end %> + <% end %> +
+ <% else %> +
+ <% if @query.present? || @sport.present? || @live_filter || @replays_filter %> +

Nessuna squadra trovata con i filtri selezionati.

+

<%= link_to "Mostra tutte le squadre attive", public_team_pages_path, class: "btn btn-secondary" %>

+ <% else %> +

Nessuna squadra attiva al momento.

+

Quando una società avvierà dirette o pubblicherà replay, comparirà qui.

+ <%= link_to "Vai alle dirette live", public_live_index_path, class: "btn btn-secondary", style: "margin-top:12px;display:inline-block" %> + <% end %> +
+ <% end %> + + +
diff --git a/backend/app/views/public/team_pages/show.html.erb b/backend/app/views/public/team_pages/show.html.erb new file mode 100644 index 0000000..7b583ee --- /dev/null +++ b/backend/app/views/public/team_pages/show.html.erb @@ -0,0 +1,175 @@ +<% content_for :title, "#{@team.name} — #{@club.name} | Match Live TV" %> +<% content_for :meta_description, team_page_meta_description(@team, @club) %> +<% content_for :canonical_url, seo_absolute_url(public_team_page_path(@team.slug)) %> +<% content_for :head do %> + +<% end %> + +
+ + +
+
+

<%= @club.name %>

+

<%= @team.name %>

+

+ <%= @team.sport_label %> + <% if @live_sessions.any? %> + · In diretta ora + <% end %> +

+ <% if @team.description.present? %> +
<%= simple_format @team.description %>
+ <% end %> +
+ <% if @live_sessions.any? %> + <%= link_to "Guarda la diretta", public_live_path(@live_sessions.first), class: "btn btn-primary" %> + <% end %> + <%= link_to "Tutte le dirette", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %> + <% if @recordings.any? %> + <%= link_to "Archivio replay", public_replay_index_path(team_id: @team.id), class: "btn btn-secondary" %> + <% end %> +
+
+
+ <% logo = @team.effective_logo_url.presence || @club.effective_logo_url %> + <% if @team.team_photo_url.present? %> + <%= image_tag @team.team_photo_url, alt: @team.name, class: "roster-hero__photo" %> + <% elsif logo.present? %> + <%= image_tag logo, alt: @team.name, class: "roster-hero__photo roster-hero__photo--logo" %> + <% else %> + + <% end %> +
+
+ + <% if @live_sessions.any? %> +
+

In diretta adesso

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

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

+ <% if session.score_state %> +

+ <%= live_score_sets_label(session.score_state, match) %> + <% if live_score_partials_label(session.score_state).present? %> + Parziali: <%= live_score_partials_label(session.score_state) %> + <% end %> + <%= session.score_state.home_points %> - <%= session.score_state.away_points %> +

+ <% end %> +
+ <% if on_air %> + In onda + <% elsif session.paused? %> + In pausa + <% else %> + Live + <% end %> +
+ <%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %> +
+ <% end %> +
+
+ <% end %> + + <% if @upcoming_matches.any? %> +
+

">Prossime partite

+

Programmate dal club: la diretta partirà quando lo staff avvierà la trasmissione.

+
+ <% @upcoming_matches.each do |match| %> +
+

+ <%= @team.name %> vs <%= match.opponent_name %> +

+

+ <% if match.location.present? %><%= match.location %> · <% end %> + <%= match.category.presence || @team.sport_label %> +

+

<%= live_scheduled_relative(match.scheduled_at) %>

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

Replay recenti

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

<%= l_local(rec.recorded_at_or_fallback) %>

+
+ <% end %> + <% end %> +
+

+ <%= link_to "Vedi tutti i replay di #{@team.name} →", public_replay_index_path(team_id: @team.id) %> +

+
+ <% end %> + + <% if @roster_by_category %> +
+

Organico

+
+ <% TeamRosterMember::DISPLAY_ORDER.each do |category| %> + <% members = @roster_by_category[category] %> + <% next if members.blank? %> +
+
+

<%= TeamRosterMember::CATEGORY_LABELS[category] %>

+ <%= members.size %> +
+
+ <% members.each do |person| %> + <%= render "shared/roster_person_card", person: person, team: @team, editable: false, compact: true %> + <% end %> +
+
+ <% end %> +
+
+ <% end %> + + <% if @live_sessions.empty? && @upcoming_matches.empty? && @recordings.empty? %> +
+

Nessuna diretta o replay al momento.

+

Torna a controllare prima delle prossime gare: qui compariranno dirette, calendario e archivio della squadra.

+ <%= link_to "Vedi tutte le dirette", public_live_index_path, class: "btn btn-secondary" %> +
+ <% end %> +
diff --git a/backend/app/views/public/teams/details.html.erb b/backend/app/views/public/teams/details.html.erb index 365b7a0..a8b022b 100644 --- a/backend/app/views/public/teams/details.html.erb +++ b/backend/app/views/public/teams/details.html.erb @@ -55,6 +55,7 @@
<% if @can_manage %> <%= link_to "← Società", public_club_path(@club), class: "btn btn-secondary" %> + <%= link_to "Pagina pubblica", public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %> <%= link_to "Responsabili trasmissione", public_team_invite_path(@team), class: "btn btn-secondary" %> <% end %> <% if current_user.can_stream_for?(@team) %> diff --git a/backend/app/views/public/teams/edit.html.erb b/backend/app/views/public/teams/edit.html.erb index 94dd175..095e9f0 100644 --- a/backend/app/views/public/teams/edit.html.erb +++ b/backend/app/views/public/teams/edit.html.erb @@ -25,6 +25,21 @@
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: "Branding squadra (override)" %> + + <%= label_tag "team[slug]", "Indirizzo pagina pubblica" %> +

+ Genitori e tifosi trovano la squadra su + <%= MatchLiveTv.app_public_url.chomp("/") %>/squadre/<%= @team.slug %> +

+ <%= text_field_tag "team[slug]", @team.slug, pattern: "[a-z0-9]+(-[a-z0-9]+)*", title: "Solo lettere minuscole, numeri e trattini" %> + + <%= label_tag "team[roster_public]", class: "checkbox-label", style: "display:flex;align-items:center;gap:8px;margin-top:16px" do %> + <%= hidden_field_tag "team[roster_public]", "0" %> + <%= check_box_tag "team[roster_public]", "1", @team.roster_public?, id: "team_roster_public" %> + Mostra l'organico sulla pagina pubblica + <% end %> +

Se attivo, giocatori e staff sono visibili a chiunque abbia il link. Puoi lasciarlo disattivato per privacy.

+

Colori società: diff --git a/backend/app/views/recordings/_club_archive.html.erb b/backend/app/views/recordings/_club_archive.html.erb index cab1b4f..c4487ae 100644 --- a/backend/app/views/recordings/_club_archive.html.erb +++ b/backend/app/views/recordings/_club_archive.html.erb @@ -135,29 +135,56 @@ <%= rec.status_label %> - <%= form_with url: paths.update.call(rec), method: :patch, local: true, class: "replay-archive__privacy-form" do %> - <% filter_params.each { |key, value| concat hidden_field_tag(key, value) } %> - <%= select_tag "recording[privacy_status]", - options_for_select([["Pubblico", "public"], ["Privato", "unlisted"]], rec.privacy_status), - class: "replay-archive__privacy-select", - onchange: "this.form.submit()" %> - <% end %>

+ <% next_privacy = rec.publicly_listed? ? "unlisted" : "public" %> + <% privacy_label = rec.publicly_listed? ? "Pubblico" : "Privato" %> + <% privacy_hint = rec.publicly_listed? ? "Visibile a tutti — clicca per rendere privato" : "Solo con link — clicca per rendere pubblico" %> + <%= button_to paths.update.call(rec), + method: :patch, + params: filter_params.merge(recording: { privacy_status: next_privacy }), + class: "replay-archive__privacy-toggle replay-archive__privacy-toggle--#{rec.privacy_status}", + title: "#{privacy_label}: #{privacy_hint}", + form: { class: "replay-archive__privacy-form" } do %> + <% if rec.publicly_listed? %> + + <% else %> + + <% end %> + <%= privacy_label %> + <% end %> <% if ent.phone_download_enabled? && rec.ready? %> <%= link_to "MP4", public_replay_download_path(rec.stream_session_id), class: "replay-archive__action replay-archive__action--secondary", title: "Scarica MP4" %> <% end %> <% if ent.premium_full? && ent.youtube_enabled? && rec.ready? && rec.youtube_video_id.blank? %> - <%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: "Pubblica su YouTube" %> + <%= button_to "YT", paths.publish_youtube.call(rec), method: :post, class: "replay-archive__action replay-archive__action--secondary", title: "Pubblica su YouTube", form: { class: "replay-archive__action-form" } %> <% elsif rec.youtube_watch_url %> <%= link_to "YT", rec.youtube_watch_url, class: "replay-archive__action replay-archive__action--secondary", target: "_blank", rel: "noopener", title: "Apri su YouTube" %> <% end %> + <% has_youtube = rec.youtube_video_id.present? && !rec.youtube_video_id.to_s.start_with?("mock_") %> + <% has_site = rec.storage_key.present? || %w[ready processing failed].include?(rec.status) %> + <% delete_confirm = "Scegli dove eliminare il replay. L'operazione è irreversibile." %> <%= button_to paths.destroy.call(rec), method: :delete, params: filter_params, class: "replay-archive__action replay-archive__action--danger", title: "Elimina replay", - form: { data: { turbo_confirm: "Eliminare definitivamente questo replay? Verranno rimossi i file sul server e il video YouTube collegato." }, class: "replay-archive__action-form" } do %> + form: { + class: "replay-archive__action-form", + data: { + confirm: delete_confirm, + turbo_confirm: delete_confirm, + confirm_mode: "delete-replay", + has_site: has_site ? "1" : "0", + has_youtube: has_youtube ? "1" : "0" + } + } do %> ✕ <% end %>
diff --git a/backend/app/views/shared/_cookie_banner.html.erb b/backend/app/views/shared/_cookie_banner.html.erb index f0fb32d..1336d3c 100644 --- a/backend/app/views/shared/_cookie_banner.html.erb +++ b/backend/app/views/shared/_cookie_banner.html.erb @@ -1,20 +1,21 @@ -