Aggiunge i18n IT/EN/FR/DE/ES, pagine pubbliche squadre e UX archivio.

Il selettore lingua funziona sul web e sulle app native; su Android la preferenza è persistita e applicata al riavvio. Incluse anche eliminazione replay a scope e campi pagina pubblica squadra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-23 19:30:44 +02:00
co-authored by Cursor
parent 7d5d66840f
commit 1d97271555
71 changed files with 3509 additions and 197 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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?
+16 -2
View File
@@ -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)
+7 -2
View File
@@ -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
@@ -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
+21
View File
@@ -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
+73
View File
@@ -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
+42 -3
View File
@@ -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?
@@ -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
@@ -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
+3 -1
View File
@@ -6,12 +6,14 @@
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=2">
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=1">
<link rel="stylesheet" href="/marketing.css?v=42">
<% end %>
<% if controller_name == "dashboard" %>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
<script src="/admin-dashboard.js?v=1" defer></script>
<% end %>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=6" defer></script>
</head>
<body class="<%= content_for?(:body_class) ? yield(:body_class) : 'admin-body' %>">
<header class="admin-header">
+4 -2
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="it">
<html lang="<%= html_lang %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %>
<%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=38">
<link rel="stylesheet" href="/marketing.css?v=44">
</head>
<body<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
@@ -22,6 +22,8 @@
<script src="/branding-form.js?v=1" defer></script>
<script src="/roster-form.js?v=1" defer></script>
<script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=6" defer></script>
<script src="/cookie-consent.js?v=1" defer></script>
</body>
</html>
@@ -1,12 +1,12 @@
<!DOCTYPE html>
<html lang="it">
<html lang="<%= html_lang %>">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=38">
<link rel="stylesheet" href="/marketing.css?v=44">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
@@ -17,6 +17,8 @@
<%= yield %>
</main>
<%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=6" defer></script>
<script src="/cookie-consent.js?v=1" defer></script>
</body>
</html>
+2 -2
View File
@@ -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 %>
@@ -74,6 +74,8 @@
<% end %>
</td>
<td style="white-space:nowrap">
<%= 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) %>
+39 -49
View File
@@ -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 %>
<script type="application/ld+json">
@@ -10,7 +10,7 @@
"url" => seo_absolute_url(root_path),
"applicationCategory" => "SportsApplication",
"operatingSystem" => "Web, Android, iOS",
"description" => "Piattaforma per dirette live e archivio partite delle società sportive giovanili."
"description" => t("home.schema_description")
}.to_json) %>
</script>
<% end %>
@@ -20,104 +20,94 @@
<div class="hero-content">
<p class="hero-app-name">Match Live TV</p>
<p class="hero-live-badge"><span class="hero-live-dot" aria-hidden="true"></span> LIVE</p>
<h1>Ogni partita, ogni evento, per chi <span class="hero-accent">non può esserci.</span></h1>
<p class="tagline">
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.
</p>
<h1><%= raw t("home.headline_html") %></h1>
<p class="tagline"><%= t("home.tagline") %></p>
<div class="hero-cta">
<%= 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" %>
</div>
<ul class="hero-features" aria-label="Vantaggi principali">
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<span class="hero-feature-label">Dirette stabili</span>
<span class="hero-feature-label"><%= t("home.feature_stable") %></span>
</li>
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-cloud"></i></span>
<span class="hero-feature-label">Archivio sicuro</span>
<span class="hero-feature-label"><%= t("home.feature_archive") %></span>
</li>
<li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-user-group"></i></span>
<span class="hero-feature-label">Condividi con chi vuoi</span>
<span class="hero-feature-label"><%= t("home.feature_share") %></span>
</li>
<li class="hero-feature-item hero-feature-item--last">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<span class="hero-feature-label">Tutto dal tuo telefono</span>
<span class="hero-feature-label"><%= t("home.feature_phone") %></span>
</li>
</ul>
</div>
<div class="hero-visual">
<%= 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" %>
</div>
</div>
</section>
<section class="section wrap">
<h2>Come funziona lo streaming per squadre giovanili</h2>
<h2><%= t("home.how_title") %></h2>
<div class="steps">
<div class="step step--visual">
<div class="step-visual">
<%= 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" %>
</div>
<div class="step-num">01</div>
<h3>Registra la squadra</h3>
<p>La società si iscrive sul sito e sceglie il piano più adatto.</p>
<h3><%= t("home.step1_title") %></h3>
<p><%= t("home.step1_body") %></p>
</div>
<div class="step step--visual">
<div class="step-visual">
<%= 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" %>
</div>
<div class="step-num">02</div>
<h3>Invita chi trasmette</h3>
<p>Coach e volontari ricevono un invito: ognuno accede con la propria email.</p>
<h3><%= t("home.step2_title") %></h3>
<p><%= t("home.step2_body") %></p>
</div>
<div class="step step--visual">
<div class="step-visual">
<%= 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" %>
</div>
<div class="step-num">03</div>
<h3>Vai in diretta</h3>
<p>Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie.</p>
<h3><%= t("home.step3_title") %></h3>
<p><%= t("home.step3_body") %></p>
</div>
<div class="step step--visual">
<div class="step-visual">
<%= 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" %>
</div>
<div class="step-num">04</div>
<h3>Tutti guardano da casa</h3>
<p>Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.</p>
<h3><%= t("home.step4_title") %></h3>
<p><%= t("home.step4_body") %></p>
</div>
</div>
</section>
<section class="section wrap plans-teaser">
<h2>Piani per dirette live e archivio partite</h2>
<p class="plans-teaser-lead">Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.</p>
<h2><%= t("home.plans_title") %></h2>
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
<div class="plans-teaser-visual">
<%= 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" %>
</div>
<%= 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" %>
</section>
<section class="section wrap seo-prose">
<h2>Streaming partite giovanili: perché le famiglie scelgono Match Live TV</h2>
<h2><%= t("home.seo_title") %></h2>
<p><%= raw t("home.seo_p1_html") %></p>
<p><%= raw t("home.seo_p2_html") %></p>
<p>
Allenatori e dirigenti cercano un modo semplice per mandare in <strong>diretta live</strong> le partite
di <strong>pallavolo giovanile</strong>, 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 <strong>Under 14, Under 16, Under 18</strong> e settori giovanili a chi è lontano.
</p>
<p>
Un genitore che lavora, un nonno che non può viaggiare, un parente allestero: aprono un link e seguono
la partita dal browser. Se arrivano in ritardo, la <strong>registrazione in archivio</strong> (con i piani Premium)
resta disponibile per giorni. Niente account complicati per chi guarda — solo per lo staff che trasmette.
</p>
<p>
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)
) %>
</p>
</section>
@@ -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)) %>
<div class="wrap team-directory">
<h1>Squadre su Match Live TV</h1>
<p class="results-hint">
Società e squadre con dirette, replay pubblici o partite in programma.
Cerca la tua squadra e apri la pagina per seguire calendario e archivio.
</p>
<%= form_with url: public_team_pages_path, method: :get, local: true, class: "team-directory-filters" do %>
<div class="search-form team-directory-filters__search">
<input
type="search"
name="q"
value="<%= @query %>"
placeholder="Nome squadra, società, città o palestra…"
aria-label="Cerca squadra"
autocomplete="off"
/>
<button type="submit" class="btn btn-primary">Cerca</button>
</div>
<div class="team-directory-filters__row">
<div class="team-directory-filters__field">
<label for="sport" class="team-directory-filters__label">Sport</label>
<select name="sport" id="sport" class="team-directory-filters__select" onchange="this.form.requestSubmit()">
<option value="">Tutti gli sport</option>
<% @sport_options.each do |sport| %>
<option value="<%= sport[:key] %>"<%= " selected" if @sport == sport[:key] %>><%= sport[:label] %></option>
<% end %>
</select>
</div>
<div class="team-directory-filters__field team-directory-filters__field--chips">
<span class="team-directory-filters__label" id="team-filter-chips-label">Mostra</span>
<div class="team-directory-chips" role="group" aria-labelledby="team-filter-chips-label">
<label class="filter-chip<%= " is-active" if @live_filter %>">
<%= check_box_tag :live, "1", @live_filter, id: "filter_live", class: "filter-chip__input", onchange: "this.form.requestSubmit()" %>
<span class="filter-chip__face" aria-hidden="true">
<span class="filter-chip__dot filter-chip__dot--live"></span>
In diretta
</span>
</label>
<label class="filter-chip<%= " is-active" if @replays_filter %>">
<%= check_box_tag :replays, "1", @replays_filter, id: "filter_replays", class: "filter-chip__input", onchange: "this.form.requestSubmit()" %>
<span class="filter-chip__face" aria-hidden="true">
<span class="filter-chip__icon"></span>
Con replay
</span>
</label>
</div>
</div>
<% if @query.present? || @sport.present? || @live_filter || @replays_filter %>
<div class="team-directory-filters__actions">
<span class="team-directory-filters__label team-directory-filters__label--spacer" aria-hidden="true">&nbsp;</span>
<%= link_to "Azzera", public_team_pages_path, class: "btn btn-secondary team-directory-filters__reset" %>
</div>
<% end %>
</div>
<% end %>
<% if @query.present? || @sport.present? || @live_filter || @replays_filter %>
<p class="results-hint">
<%= @entries.size %> squadre trovate
<% if @query.present? %> per «<%= h @query %>»<% end %>
<% if @sport.present? %> · <%= Sports::Catalog.find_optional(@sport)&.dig(:label) || @sport %><% end %>
</p>
<% else %>
<p class="results-hint"><%= @entries.size %> squadre attive</p>
<% end %>
<% if @entries.any? %>
<div class="team-directory-grid">
<% @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 %>
<div class="team-directory-card__media">
<% 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 %>
<div class="team-directory-card__photo team-directory-card__photo--fallback" aria-hidden="true">
<%= team.name.first(2).upcase %>
</div>
<% end %>
</div>
<div class="team-directory-card__body">
<p class="team-directory-card__club"><%= club.name %></p>
<h2 class="team-directory-card__name"><%= team.name %></h2>
<p class="team-directory-card__meta"><%= team.sport_label %></p>
<div class="team-directory-card__badges">
<% if entry.live_now %>
<span class="badge badge-on-air">In diretta</span>
<% end %>
<% if entry.upcoming_count.positive? %>
<span class="badge badge-scheduled"><%= entry.upcoming_count %> in programma</span>
<% end %>
<% if entry.replay_count.positive? %>
<span class="badge badge-wait"><%= entry.replay_count %> replay</span>
<% end %>
</div>
</div>
<% end %>
<% end %>
</div>
<% else %>
<div class="empty-state">
<% if @query.present? || @sport.present? || @live_filter || @replays_filter %>
<p><strong>Nessuna squadra trovata</strong> con i filtri selezionati.</p>
<p><%= link_to "Mostra tutte le squadre attive", public_team_pages_path, class: "btn btn-secondary" %></p>
<% else %>
<p><strong>Nessuna squadra attiva al momento.</strong></p>
<p>Quando una società avvierà dirette o pubblicherà replay, comparirà qui.</p>
<%= link_to "Vai alle dirette live", public_live_index_path, class: "btn btn-secondary", style: "margin-top:12px;display:inline-block" %>
<% end %>
</div>
<% end %>
<p class="team-directory-footer muted">
<%= link_to "Dirette in corso", public_live_index_path %> ·
<%= link_to "Archivio replay", public_replay_index_path %>
</p>
</div>
@@ -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 %>
<script type="application/ld+json">
<%= raw(team_page_structured_data(@team, @club).to_json) %>
</script>
<% end %>
<div class="wrap team-public-page">
<nav class="team-dashboard-nav" aria-label="Percorso">
<%= link_to "← Tutte le squadre", public_team_pages_path, class: "team-dashboard-nav__club" %>
</nav>
<header class="roster-hero card team-public-hero" style="--club-primary:<%= @team.effective_primary_color %>;--club-secondary:<%= @team.effective_secondary_color %>">
<div class="roster-hero__intro">
<p class="roster-hero__club"><%= @club.name %></p>
<h1 class="roster-hero__title"><%= @team.name %></h1>
<p class="team-public-meta">
<%= @team.sport_label %>
<% if @live_sessions.any? %>
· <span class="hero-live-dot" aria-hidden="true"></span> <strong>In diretta ora</strong>
<% end %>
</p>
<% if @team.description.present? %>
<div class="roster-hero__desc"><%= simple_format @team.description %></div>
<% end %>
<div class="team-public-actions">
<% 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 %>
</div>
</div>
<div class="roster-hero__media">
<% 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 %>
<div class="roster-hero__photo roster-hero__photo--empty team-public-logo-fallback" aria-hidden="true">
<span><%= @team.name.first(2).upcase %></span>
</div>
<% end %>
</div>
</header>
<% if @live_sessions.any? %>
<section class="team-public-section" aria-labelledby="team-live-heading">
<h2 id="team-live-heading" class="section-heading">In diretta adesso</h2>
<div class="live-grid">
<% @live_sessions.each do |session| %>
<% match = session.match %>
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
<article class="live-card">
<%= live_match_card_heading(match, link_team: false) %>
<p class="meta">
<% if match.location.present? %><%= match.location %> · <% end %>
Match Live TV
</p>
<% if session.score_state %>
<p class="card-score">
<span class="card-sets"><%= live_score_sets_label(session.score_state, match) %></span>
<% if live_score_partials_label(session.score_state).present? %>
<span class="card-partials">Parziali: <%= live_score_partials_label(session.score_state) %></span>
<% end %>
<span class="card-points"><%= session.score_state.home_points %> - <%= session.score_state.away_points %></span>
</p>
<% end %>
<div class="badges">
<% if on_air %>
<span class="badge badge-on-air">In onda</span>
<% elsif session.paused? %>
<span class="badge badge-connecting">In pausa</span>
<% else %>
<span class="badge badge-live">Live</span>
<% end %>
</div>
<%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %>
</article>
<% end %>
</div>
</section>
<% end %>
<% if @upcoming_matches.any? %>
<section class="team-public-section" aria-labelledby="team-upcoming-heading">
<h2 id="team-upcoming-heading" class="section-heading<%= " section-heading--spaced" if @live_sessions.any? %>">Prossime partite</h2>
<p class="results-hint">Programmate dal club: la diretta partirà quando lo staff avvierà la trasmissione.</p>
<div class="live-grid upcoming-grid">
<% @upcoming_matches.each do |match| %>
<article class="live-card live-card--upcoming">
<h3 class="live-card__title">
<span class="live-card__matchup"><%= @team.name %> vs <%= match.opponent_name %></span>
</h3>
<p class="meta">
<% if match.location.present? %><%= match.location %> · <% end %>
<%= match.category.presence || @team.sport_label %>
</p>
<p class="upcoming-when"><%= live_scheduled_relative(match.scheduled_at) %></p>
<div class="badges">
<span class="badge badge-scheduled">In programma</span>
</div>
</article>
<% end %>
</div>
</section>
<% end %>
<% if @recordings.any? %>
<section class="team-public-section" aria-labelledby="team-replays-heading">
<h2 id="team-replays-heading" class="section-heading section-heading--spaced">Replay recenti</h2>
<div class="replay-grid">
<% @recordings.each do |rec| %>
<% match = rec.stream_session.match %>
<%= link_to public_replay_path(rec.stream_session_id), class: "replay-card" do %>
<div class="replay-card__media">
<% if rec.thumbnail_url %>
<img src="<%= rec.thumbnail_url %>" alt="" class="replay-card__thumb" loading="lazy" width="320" height="180">
<% else %>
<div class="replay-card__thumb replay-card__thumb--placeholder" aria-hidden="true">
<span class="replay-card__placeholder-icon"></span>
</div>
<% end %>
<span class="replay-card__duration"><%= rec.duration_label %></span>
<span class="replay-card__play" aria-hidden="true"></span>
</div>
<div class="replay-card__body">
<strong class="replay-card__title"><%= rec.title_or_default %></strong>
<p class="replay-card__meta"><%= l_local(rec.recorded_at_or_fallback) %></p>
</div>
<% end %>
<% end %>
</div>
<p class="team-public-more">
<%= link_to "Vedi tutti i replay di #{@team.name} →", public_replay_index_path(team_id: @team.id) %>
</p>
</section>
<% end %>
<% if @roster_by_category %>
<section class="team-public-section" aria-labelledby="team-roster-heading">
<h2 id="team-roster-heading" class="section-heading section-heading--spaced">Organico</h2>
<div class="team-public-roster">
<% TeamRosterMember::DISPLAY_ORDER.each do |category| %>
<% members = @roster_by_category[category] %>
<% next if members.blank? %>
<section class="roster-section card">
<header class="roster-section__head">
<h3 class="roster-section__title"><%= TeamRosterMember::CATEGORY_LABELS[category] %></h3>
<span class="roster-section__count"><%= members.size %></span>
</header>
<div class="roster-list">
<% members.each do |person| %>
<%= render "shared/roster_person_card", person: person, team: @team, editable: false, compact: true %>
<% end %>
</div>
</section>
<% end %>
</div>
</section>
<% end %>
<% if @live_sessions.empty? && @upcoming_matches.empty? && @recordings.empty? %>
<div class="empty-state empty-state--soft team-public-empty">
<p><strong>Nessuna diretta o replay al momento.</strong></p>
<p>Torna a controllare prima delle prossime gare: qui compariranno dirette, calendario e archivio della squadra.</p>
<%= link_to "Vedi tutte le dirette", public_live_index_path, class: "btn btn-secondary" %>
</div>
<% end %>
</div>
@@ -55,6 +55,7 @@
<div class="team-details-actions">
<% 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) %>
@@ -25,6 +25,21 @@
</div>
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: "Branding squadra (override)" %>
<%= label_tag "team[slug]", "Indirizzo pagina pubblica" %>
<p class="branding-hint" style="margin-top:0">
Genitori e tifosi trovano la squadra su
<strong><%= MatchLiveTv.app_public_url.chomp("/") %>/squadre/<span id="team-slug-preview"><%= @team.slug %></span></strong>
</p>
<%= 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 %>
<p class="branding-hint">Se attivo, giocatori e staff sono visibili a chiunque abbia il link. Puoi lasciarlo disattivato per privacy.</p>
<p class="branding-hint">
Colori società:
<span class="color-swatch" style="background:<%= @club.effective_primary_color %>"></span>
@@ -135,29 +135,56 @@
</td>
<td class="replay-archive-table__col-status">
<span class="replay-archive__status replay-archive__status--<%= rec.status %>"><%= rec.status_label %></span>
<%= 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 %>
</td>
<td class="replay-archive-table__col-actions">
<div class="replay-archive__actions">
<% 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? %>
<svg class="replay-archive__privacy-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<circle cx="12" cy="12" r="9" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M3 12h18M12 3c2.8 2.6 4.2 5.6 4.2 9s-1.4 6.4-4.2 9c-2.8-2.6-4.2-5.6-4.2-9S9.2 5.6 12 3z" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
<% else %>
<svg class="replay-archive__privacy-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<rect x="5" y="11" width="14" height="10" rx="2" fill="none" stroke="currentColor" stroke-width="1.8"/>
<path d="M8 11V8a4 4 0 0 1 8 0v3" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
</svg>
<% end %>
<span class="visually-hidden"><%= privacy_label %></span>
<% 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 %>
</div>
@@ -1,20 +1,21 @@
<div id="cookie-banner" class="cookie-banner" role="dialog" aria-labelledby="cookie-banner-title" aria-describedby="cookie-banner-desc" hidden>
<div class="cookie-banner__inner">
<div class="cookie-banner__text">
<p id="cookie-banner-title" class="cookie-banner__title">Cookie e privacy</p>
<p id="cookie-banner-title" class="cookie-banner__title"><%= t("cookie.title") %></p>
<p id="cookie-banner-desc" class="cookie-banner__desc">
Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche
<strong>Google Analytics</strong> per statistiche aggregate sul sito.
<%= link_to "Cookie policy", public_cookies_path, class: "cookie-banner__link" %>
e <%= link_to "Privacy", public_privacy_path, class: "cookie-banner__link" %>.
<%= raw t(
"cookie.body_html",
cookie_link: link_to(t("cookie.policy"), public_cookies_path, class: "cookie-banner__link"),
privacy_link: link_to(t("cookie.privacy"), public_privacy_path, class: "cookie-banner__link")
) %>
</p>
</div>
<div class="cookie-banner__actions">
<button type="button" class="btn btn-secondary cookie-banner__btn" data-cookie-reject>
Solo necessari
<%= t("cookie.reject") %>
</button>
<button type="button" class="btn btn-primary cookie-banner__btn" data-cookie-accept-all>
Accetta tutti
<%= t("cookie.accept_all") %>
</button>
</div>
</div>
@@ -0,0 +1,71 @@
<%# Selettore lingua a bandiere (allineato a destra nel menu) %>
<% current = current_language_option %>
<div class="lang-switcher" data-lang-switcher>
<button
type="button"
class="lang-switcher__toggle"
id="lang-switcher-toggle"
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="lang-switcher-menu"
aria-label="<%= t('language.choose') %>: <%= current[:native] %>"
title="<%= current[:native] %>"
>
<span class="lang-switcher__flag" aria-hidden="true"><%= current[:flag] %></span>
<span class="lang-switcher__chevron" aria-hidden="true"></span>
</button>
<div
class="lang-switcher__menu"
id="lang-switcher-menu"
role="listbox"
aria-label="<%= t('language.label') %>"
hidden
>
<% language_options.each do |opt| %>
<% selected = opt[:code].to_s == current_locale.to_s %>
<%= button_to public_locale_path,
method: :patch,
params: { locale: opt[:code] },
class: "lang-switcher__option#{' is-active' if selected}",
form: { class: "lang-switcher__option-form" },
role: "option",
aria: { selected: selected, label: opt[:native] },
title: opt[:native] do %>
<span class="lang-switcher__flag" aria-hidden="true"><%= opt[:flag] %></span>
<span class="visually-hidden"><%= opt[:native] %></span>
<% end %>
<% end %>
</div>
</div>
<script>
(function () {
var root = document.querySelector("[data-lang-switcher]");
if (!root || root.dataset.bound === "1") return;
root.dataset.bound = "1";
var toggle = root.querySelector(".lang-switcher__toggle");
var menu = root.querySelector(".lang-switcher__menu");
if (!toggle || !menu) return;
function setOpen(open) {
menu.hidden = !open;
toggle.setAttribute("aria-expanded", open ? "true" : "false");
root.classList.toggle("is-open", open);
}
toggle.addEventListener("click", function (e) {
e.stopPropagation();
setOpen(menu.hidden);
});
document.addEventListener("click", function (e) {
if (!root.contains(e.target)) setOpen(false);
});
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") setOpen(false);
});
})();
</script>
@@ -1,20 +1,20 @@
<footer class="site-footer">
<div class="wrap">
<div>
<strong style="color:#fff">Match Live TV</strong>Ogni partita, ogni evento, per chi non può esserci
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
</div>
<div>
<%= link_to "Prezzi", public_prezzi_path %> ·
<%= link_to "Dirette", public_live_index_path %> ·
<%= link_to "FAQ", public_faq_path %> ·
<%= link_to "Privacy", public_privacy_path %> ·
<%= link_to "Cookie", public_cookies_path %> ·
<%= link_to "Termini", public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage>Gestisci cookie</button>
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_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 %> ·
<%= link_to t("common.terms"), public_termini_path %>
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
</div>
<div class="site-footer__legal">
<p>© 2026 Emiliano Frascaro P. IVA 14230270960</p>
<p>I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.</p>
<p><%= t("footer.copyright") %></p>
<p><%= t("footer.responsibility") %></p>
</div>
</div>
</footer>
@@ -7,35 +7,41 @@
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
<% end %>
<button type="button" class="nav-toggle" aria-label="Apri menu" aria-expanded="false" aria-controls="site-nav">
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
</button>
<div class="mast-tools">
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
<span class="nav-toggle-bar" aria-hidden="true"></span>
</button>
</div>
</div>
</div>
<nav id="site-nav" class="nav" aria-label="Menu principale" aria-hidden="true">
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
<div class="nav-panel">
<%= link_to "Home", root_path, class: (request.path == "/" ? "nav-active" : nil) %>
<%= link_to "Funzionalità", public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
<%= link_to "Prezzi", public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
<%= link_to "FAQ", public_faq_path, class: (request.path == "/faq" ? "nav-active" : nil) %>
<%= link_to "Dirette live", public_live_index_path, class: (live_section ? "nav-active" : nil) %>
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
<%= 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) %>
<div class="nav-actions">
<% if logged_in? %>
<% if current_user.primary_club || current_user.manageable_teams.any? %>
<% if current_user.primary_club %>
<%= link_to "Società", public_club_path(current_user.primary_club), class: "nav-link-item" %>
<%= link_to t("nav.my_club"), public_club_path(current_user.primary_club), class: "nav-link-item" %>
<% elsif current_user.manageable_teams.first %>
<%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
<% end %>
<% end %>
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
<% else %>
<%= link_to "Accedi", public_login_path, class: "nav-link-item" %>
<%= link_to "Registra squadra", public_signup_path, class: "btn btn-primary nav-btn" %>
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
<% end %>
<div class="nav-lang">
<%= render "shared/language_switcher" %>
</div>
</div>
</div>
</nav>
@@ -50,12 +56,14 @@
var backdrop = document.getElementById("nav-backdrop");
var nav = document.getElementById("site-nav");
if (!chrome || !toggle || !nav) return;
var openLabel = <%= raw t("nav.open_menu").to_json %>;
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
function setOpen(open) {
chrome.classList.toggle("nav-open", open);
document.body.classList.toggle("nav-menu-open", open);
toggle.setAttribute("aria-expanded", open ? "true" : "false");
toggle.setAttribute("aria-label", open ? "Chiudi menu" : "Apri menu");
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
nav.setAttribute("aria-hidden", open ? "false" : "true");
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
}
+4 -4
View File
@@ -1,5 +1,5 @@
<% description = content_for?(:meta_description) ? yield(:meta_description) : "Diretta live delle partite giovanili dal telefono: genitori e nonni guardano dal browser, senza app. Archivio partite per non perderle." %>
<% page_title = content_for?(:title) ? yield(:title) : "Match Live TV — Diretta live partite giovanili" %>
<% description = content_for?(:meta_description) ? yield(:meta_description) : t("meta.default_description") %>
<% page_title = content_for?(:title) ? yield(:title) : t("meta.default_title") %>
<meta name="description" content="<%= description %>">
<meta name="application-name" content="Match Live TV">
<meta name="robots" content="<%= seo_robots_content %>">
@@ -9,9 +9,9 @@
<meta property="og:description" content="<%= description %>">
<meta property="og:type" content="website">
<meta property="og:url" content="<%= seo_canonical_url %>">
<meta property="og:locale" content="it_IT">
<meta property="og:locale" content="<%= og_locale_tag %>">
<meta property="og:image" content="<%= seo_og_image_url %>">
<meta property="og:image:alt" content="Match Live TV — diretta live partite giovanili da smartphone">
<meta property="og:image:alt" content="<%= t('meta.og_image_alt') %>">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="<%= page_title %>">
<meta name="twitter:description" content="<%= description %>">
+8 -2
View File
@@ -28,8 +28,14 @@ module App
config.active_job.queue_adapter = :sidekiq
config.time_zone = "Europe/Rome"
config.i18n.default_locale = :it
config.i18n.available_locales = %i[it en]
config.i18n.fallbacks = { it: %i[it en], en: %i[en it] }
config.i18n.available_locales = %i[it en fr de es]
config.i18n.fallbacks = {
it: %i[it en],
en: %i[en it],
fr: %i[fr en it],
de: %i[de en it],
es: %i[es en it]
}
config.generators { |g| g.orm :active_record, primary_key_type: :uuid }
end
end
+2 -2
View File
@@ -1,5 +1,5 @@
# Sito in italiano: locale fissa per ogni richiesta web (evita date tipo "July 02, 2026").
# Locales disponibili sul sito pubblico (IT default + EN/FR/DE/ES).
Rails.application.config.after_initialize do
I18n.available_locales = %i[it en]
I18n.available_locales = %i[it en fr de es]
I18n.default_locale = :it
end
+92
View File
@@ -0,0 +1,92 @@
de:
language:
label: Sprache
choose: Sprache wählen
nav:
main_menu: Hauptmenü
open_menu: Menü öffnen
close_menu: Menü schließen
home: Start
features: Funktionen
pricing: Preise
faq: FAQ
live: Live-Spiele
teams: Teams
my_club: Mein Verein
my_team: Mein Team
login: Anmelden
logout: Abmelden
signup: Team registrieren
footer:
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
live: Live
manage_cookies: Cookies verwalten
copyright: "© 2026 Emiliano Frascaro USt-IdNr. 14230270960"
responsibility: Die übertragenen Inhalte liegen in der alleinigen Verantwortung der Sportvereine, die sie veröffentlichen.
cookie:
title: Cookies und Datenschutz
body_html: Wir verwenden notwendige Cookies für Login und Sicherheit. Mit Ihrer Zustimmung aktivieren wir auch <strong>Google Analytics</strong> für aggregierte Website-Statistiken. %{cookie_link} und %{privacy_link}.
policy: Cookie-Richtlinie
privacy: Datenschutz
reject: Nur notwendige
accept_all: Alle akzeptieren
meta:
default_title: Match Live TV — Live-Jugendspiele vom Handy
default_description: "Jugendspiele live vom Handy streamen: Eltern und Großeltern schauen im Browser, ohne App. Spielarchiv, damit nichts verloren geht."
og_image_alt: Match Live TV — Live-Jugendspiele vom Smartphone
home:
title: Match Live TV — Live-Jugendspiele vom Handy
meta_description: "Können Sie nicht in die Halle? Sehen Sie das Spiel Ihres Kindes live am Handy oder Computer — ohne App. Volleyball, Fußball und Jugendsport: Link mit Großeltern und Verwandten teilen, Replay im Archiv."
schema_description: Plattform für Livestreams und Archive von Jugend-Sportvereinen.
headline_html: Jedes Spiel, jedes Event, für alle, die <span class="hero-accent">nicht dabei sein können.</span>
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
cta_signup: Team registrieren
cta_live: Live-Spiele ansehen
features_aria: Wichtige Vorteile
feature_stable: Stabile Streams
feature_archive: Sicheres Archiv
feature_share: Teilen mit wem Sie wollen
feature_phone: Alles vom Handy
hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
how_title: So funktioniert Streaming für Jugendteams
step1_title: Team registrieren
step1_body: Der Verein meldet sich auf der Website an und wählt den passenden Plan.
step2_title: Streamer einladen
step2_body: "Trainer und Helfer erhalten eine Einladung: jeder meldet sich mit seiner E-Mail an."
step3_title: Live gehen
step3_body: "Am Handy starten Sie das Spiel: Live-Ergebnis und Link zum Teilen mit Familien."
step4_title: Alle schauen von zu Hause
step4_body: Eltern, Großeltern und Verwandte öffnen den Link im Browser. Das Spiel bleibt auch im Archiv.
plans_title: Pläne für Livestreams und Spielarchiv
plans_lead: Kostenlos starten. Mit Premium mehr gleichzeitige Spiele, längeres Archiv und YouTube-Livestream.
plans_alt: "Match Live TV Ökosystem: Live-Smartphone, Laptop mit Live und Statistik, Tablet mit Archiv und Cloud"
plans_cta: Pläne vergleichen
seo_title: "Jugendspiel-Streaming: warum Familien Match Live TV wählen"
seo_p1_html: Trainer und Vorstände wollen einfach <strong>live</strong> gehen für <strong>Jugendvolleyball</strong>, Fußball, Basketball und mehr — ohne TV-Technik, nur mit Smartphone in der Halle. Match Live TV ist für Amateurvereine gebaut, die <strong>U14, U16, U18</strong> für Entfernte sichtbar machen wollen.
seo_p2_html: "Ein Elternteil bei der Arbeit, ein Großelternteil das nicht reisen kann, ein Verwandter im Ausland: sie öffnen einen Link und folgen dem Spiel im Browser. Bei Verspätung bleibt die <strong>Archivaufnahme</strong> (Premium) Tage verfügbar. Keine komplizierten Konten für Zuschauer — nur für das streamingende Team."
seo_p3_html: "Fragen zur Nutzung? Lesen Sie die %{faq_link}, entdecken Sie %{volleyball_link} oder %{signup_link}."
seo_faq_link: FAQ
seo_volleyball_link: Match Live TV für Jugendvolleyball
seo_signup_link: Team kostenlos registrieren
auth:
login_title: Anmelden
email: E-Mail
password: Passwort
submit_login: Anmelden
forgot_password: Passwort vergessen?
no_account: Noch kein Konto?
signup_link: Team registrieren
signup_title: Team registrieren
common:
privacy: Datenschutz
cookies: Cookies
terms: AGB
pricing: Preise
faq: FAQ
save: Speichern
cancel: Abbrechen
delete: Löschen
confirm: Bestätigen
loading: Wird geladen…
error: Es ist ein Fehler aufgetreten
back: Zurück
+92
View File
@@ -0,0 +1,92 @@
en:
language:
label: Language
choose: Choose language
nav:
main_menu: Main menu
open_menu: Open menu
close_menu: Close menu
home: Home
features: Features
pricing: Pricing
faq: FAQ
live: Live matches
teams: Teams
my_club: My club
my_team: My team
login: Log in
logout: Log out
signup: Register a team
footer:
tagline: Every match, every event, for those who can't be there
live: Live
manage_cookies: Manage cookies
copyright: "© 2026 Emiliano Frascaro VAT 14230270960"
responsibility: Broadcast content is the sole responsibility of the sports clubs that publish it.
cookie:
title: Cookies and privacy
body_html: We use necessary cookies for login and security. With your consent we also enable <strong>Google Analytics</strong> for aggregate site statistics. %{cookie_link} and %{privacy_link}.
policy: Cookie policy
privacy: Privacy
reject: Necessary only
accept_all: Accept all
meta:
default_title: Match Live TV — Live youth sports streaming from your phone
default_description: "Live stream youth matches from your phone: parents and grandparents watch in the browser, no app required. Match archive so you never miss a game."
og_image_alt: Match Live TV — live youth match streaming from a smartphone
home:
title: Match Live TV — Live youth matches from your phone
meta_description: "Can't make it to the gym? Watch your child's match live from your phone or computer—no app to install. Volleyball, football and youth sports: share a link with grandparents and relatives, and catch the replay if you miss it."
schema_description: Platform for live streaming and archiving youth sports club matches.
headline_html: Every match, every event, for those who <span class="hero-accent">can't be there.</span>
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
cta_signup: Register your team
cta_live: Watch live matches
features_aria: Key benefits
feature_stable: Reliable streams
feature_archive: Secure archive
feature_share: Share with anyone
feature_phone: All from your phone
hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
how_title: How streaming works for youth teams
step1_title: Register the team
step1_body: The club signs up on the site and chooses the right plan.
step2_title: Invite who streams
step2_body: "Coaches and volunteers get an invite: each person signs in with their own email."
step3_title: Go live
step3_body: "From the phone you start the match: live score and a link to share with families."
step4_title: Everyone watches from home
step4_body: Parents, grandparents and relatives open the link in the browser. The match also stays in the archive.
plans_title: Plans for live streams and match archive
plans_lead: Start free. Upgrade to Premium when you need more concurrent matches, longer archive and YouTube streaming.
plans_alt: "Match Live TV ecosystem: live smartphone, laptop with live stats, tablet with match archive and cloud"
plans_cta: Compare plans
seo_title: "Youth match streaming: why families choose Match Live TV"
seo_p1_html: Coaches and club managers want a simple way to <strong>go live</strong> for <strong>youth volleyball</strong>, football, basketball and more — no TV gear, just a smartphone in the gym. Match Live TV is built for amateur clubs that want <strong>U14, U16, U18</strong> and youth teams watched by people far away.
seo_p2_html: "A parent at work, a grandparent who can't travel, a relative abroad: they open a link and follow the match in the browser. If they arrive late, the <strong>archive recording</strong> (on Premium plans) stays available for days. No complicated accounts for viewers — only for the staff who streams."
seo_p3_html: "Questions about how it works? Read the %{faq_link}, discover %{volleyball_link} or %{signup_link}."
seo_faq_link: FAQ
seo_volleyball_link: Match Live TV for youth volleyball
seo_signup_link: register your team for free
auth:
login_title: Log in
email: Email
password: Password
submit_login: Log in
forgot_password: Forgot password?
no_account: Don't have an account?
signup_link: Register a team
signup_title: Register your team
common:
privacy: Privacy
cookies: Cookies
terms: Terms
pricing: Pricing
faq: FAQ
save: Save
cancel: Cancel
delete: Delete
confirm: Confirm
loading: Loading…
error: Something went wrong
back: Back
+92
View File
@@ -0,0 +1,92 @@
es:
language:
label: Idioma
choose: Elegir idioma
nav:
main_menu: Menú principal
open_menu: Abrir menú
close_menu: Cerrar menú
home: Inicio
features: Funciones
pricing: Precios
faq: FAQ
live: Directos
teams: Equipos
my_club: Mi club
my_team: Mi equipo
login: Acceder
logout: Salir
signup: Registrar equipo
footer:
tagline: Cada partido, cada evento, para quien no puede estar
live: Directos
manage_cookies: Gestionar cookies
copyright: "© 2026 Emiliano Frascaro NIF 14230270960"
responsibility: Los contenidos emitidos son responsabilidad exclusiva de los clubes deportivos que los publican.
cookie:
title: Cookies y privacidad
body_html: Usamos cookies necesarias para el inicio de sesión y la seguridad. Con tu consentimiento también activamos <strong>Google Analytics</strong> para estadísticas agregadas del sitio. %{cookie_link} y %{privacy_link}.
policy: Política de cookies
privacy: Privacidad
reject: Solo necesarias
accept_all: Aceptar todas
meta:
default_title: Match Live TV — Directo de partidos juveniles desde el móvil
default_description: "Emite en directo partidos juveniles desde el móvil: padres y abuelos miran en el navegador, sin app. Archivo de partidos para no perdértelos."
og_image_alt: Match Live TV — directo de partidos juveniles desde el smartphone
home:
title: Match Live TV — Directo de partidos juveniles desde el móvil
meta_description: "¿No puedes ir al pabellón? Mira el partido de tu hijo en directo desde el móvil o el ordenador, sin instalar apps. Voleibol, fútbol y deporte juvenil: comparte el enlace con abuelos y familiares, y recupera la repetición si te lo perdiste."
schema_description: Plataforma de directos y archivo de partidos de clubes deportivos juveniles.
headline_html: Cada partido, cada evento, para quien <span class="hero-accent">no puede estar.</span>
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
cta_signup: Registra tu equipo
cta_live: Ver directos
features_aria: Ventajas principales
feature_stable: Directos estables
feature_archive: Archivo seguro
feature_share: Comparte con quien quieras
feature_phone: Todo desde tu móvil
hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
how_title: Cómo funciona el streaming para equipos juveniles
step1_title: Registra el equipo
step1_body: El club se registra en el sitio y elige el plan adecuado.
step2_title: Invita a quien emite
step2_body: "Entrenadores y voluntarios reciben una invitación: cada uno accede con su email."
step3_title: Sal en directo
step3_body: "Desde el móvil arrancas el partido: marcador en vivo y enlace para compartir con las familias."
step4_title: Todos miran desde casa
step4_body: Padres, abuelos y familiares abren el enlace en el navegador. El partido también queda en el archivo.
plans_title: Planes para directos y archivo de partidos
plans_lead: Empieza gratis. Pasa a Premium para más partidos a la vez, archivo más largo y directo en YouTube.
plans_alt: "Ecosistema Match Live TV: smartphone en directo, portátil con live y estadísticas, tablet con archivo y nube"
plans_cta: Comparar planes
seo_title: "Streaming de partidos juveniles: por qué las familias eligen Match Live TV"
seo_p1_html: Entrenadores y directivos buscan una forma sencilla de emitir en <strong>directo</strong> partidos de <strong>voleibol juvenil</strong>, fútbol, baloncesto y más — sin equipo de TV, solo con el smartphone en el pabellón. Match Live TV está pensado para clubes amateur que quieren que vean los <strong>U14, U16, U18</strong> desde lejos.
seo_p2_html: "Un padre en el trabajo, un abuelo que no puede viajar, un familiar en el extranjero: abren un enlace y siguen el partido en el navegador. Si llegan tarde, la <strong>grabación en archivo</strong> (planes Premium) permanece días. Sin cuentas complicadas para quien mira — solo para el staff que emite."
seo_p3_html: "¿Dudas sobre cómo funciona? Lee las %{faq_link}, descubre %{volleyball_link} o %{signup_link}."
seo_faq_link: preguntas frecuentes
seo_volleyball_link: Match Live TV para voleibol juvenil
seo_signup_link: registra el equipo gratis
auth:
login_title: Acceder
email: Email
password: Contraseña
submit_login: Acceder
forgot_password: ¿Olvidaste la contraseña?
no_account: ¿No tienes cuenta?
signup_link: Registra el equipo
signup_title: Registra tu equipo
common:
privacy: Privacidad
cookies: Cookies
terms: Términos
pricing: Precios
faq: FAQ
save: Guardar
cancel: Cancelar
delete: Eliminar
confirm: Confirmar
loading: Cargando…
error: Se ha producido un error
back: Atrás
+92
View File
@@ -0,0 +1,92 @@
fr:
language:
label: Langue
choose: Choisir la langue
nav:
main_menu: Menu principal
open_menu: Ouvrir le menu
close_menu: Fermer le menu
home: Accueil
features: Fonctionnalités
pricing: Tarifs
faq: FAQ
live: Directs
teams: Équipes
my_club: Mon club
my_team: Mon équipe
login: Connexion
logout: Déconnexion
signup: Inscrire une équipe
footer:
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
live: Directs
manage_cookies: Gérer les cookies
copyright: "© 2026 Emiliano Frascaro TVA 14230270960"
responsibility: Les contenus diffusés relèvent de la seule responsabilité des clubs sportifs qui les publient.
cookie:
title: Cookies et confidentialité
body_html: Nous utilisons des cookies nécessaires pour la connexion et la sécurité. Avec votre consentement, nous activons aussi <strong>Google Analytics</strong> pour des statistiques agrégées. %{cookie_link} et %{privacy_link}.
policy: Politique cookies
privacy: Confidentialité
reject: Nécessaires uniquement
accept_all: Tout accepter
meta:
default_title: Match Live TV — Direct des matchs jeunes depuis le téléphone
default_description: "Diffusez en direct les matchs jeunes depuis le téléphone : parents et grands-parents regardent dans le navigateur, sans application. Archive des matchs pour ne rien manquer."
og_image_alt: Match Live TV — direct des matchs jeunes depuis un smartphone
home:
title: Match Live TV — Direct des matchs jeunes depuis le téléphone
meta_description: "Vous ne pouvez pas aller au gymnase ? Regardez le match de votre enfant en direct depuis le téléphone ou l'ordinateur, sans installer d'appli. Volleyball, football et sports jeunes : partagez le lien avec les proches, et retrouvez le replay si vous l'avez manqué."
schema_description: Plateforme de direct et d'archivage des matchs des clubs sportifs jeunes.
headline_html: Chaque match, chaque événement, pour ceux qui <span class="hero-accent">ne peuvent pas être là.</span>
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
cta_signup: Inscrire votre équipe
cta_live: Voir les directs
features_aria: Avantages principaux
feature_stable: Directs stables
feature_archive: Archive sécurisée
feature_share: Partagez avec qui vous voulez
feature_phone: Tout depuis votre téléphone
hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
how_title: Comment fonctionne le streaming pour les équipes jeunes
step1_title: Inscrire l'équipe
step1_body: Le club s'inscrit sur le site et choisit l'offre adaptée.
step2_title: Inviter qui diffuse
step2_body: "Coachs et bénévoles reçoivent une invitation : chacun se connecte avec son email."
step3_title: Passer en direct
step3_body: "Depuis le téléphone, démarrez le match : score en direct et lien à partager avec les familles."
step4_title: Tout le monde regarde de chez soi
step4_body: Parents, grands-parents et proches ouvrent le lien dans le navigateur. Le match reste aussi dans l'archive.
plans_title: Offres pour directs et archive des matchs
plans_lead: Commencez gratuitement. Passez en Premium pour plus de matchs simultanés, une archive plus longue et le direct YouTube.
plans_alt: "Écosystème Match Live TV : smartphone en direct, laptop avec live et stats, tablette avec archive et cloud"
plans_cta: Comparer les offres
seo_title: "Streaming des matchs jeunes : pourquoi les familles choisissent Match Live TV"
seo_p1_html: Entraîneurs et dirigeants veulent un moyen simple de passer en <strong>direct</strong> les matchs de <strong>volley jeunes</strong>, football, basket et plus — sans matériel TV, juste un smartphone en salle. Match Live TV est pensé pour les clubs amateurs qui veulent faire regarder les <strong>U14, U16, U18</strong> à distance.
seo_p2_html: "Un parent au travail, un grand-parent qui ne peut pas voyager, un proche à l'étranger : ils ouvrent un lien et suivent le match dans le navigateur. S'ils arrivent en retard, l'<strong>enregistrement en archive</strong> (offres Premium) reste disponible des jours. Pas de compte compliqué pour les spectateurs — seulement pour le staff qui diffuse."
seo_p3_html: "Des questions ? Lisez les %{faq_link}, découvrez %{volleyball_link} ou %{signup_link}."
seo_faq_link: FAQ
seo_volleyball_link: Match Live TV pour le volley jeunes
seo_signup_link: inscrivez l'équipe gratuitement
auth:
login_title: Connexion
email: E-mail
password: Mot de passe
submit_login: Connexion
forgot_password: Mot de passe oublié ?
no_account: Pas encore de compte ?
signup_link: Inscrire l'équipe
signup_title: Inscrire votre équipe
common:
privacy: Confidentialité
cookies: Cookies
terms: Conditions
pricing: Tarifs
faq: FAQ
save: Enregistrer
cancel: Annuler
delete: Supprimer
confirm: Confirmer
loading: Chargement…
error: Une erreur s'est produite
back: Retour
+92
View File
@@ -0,0 +1,92 @@
it:
language:
label: Lingua
choose: Scegli la lingua
nav:
main_menu: Menu principale
open_menu: Apri menu
close_menu: Chiudi menu
home: Home
features: Funzionalità
pricing: Prezzi
faq: FAQ
live: Dirette live
teams: Squadre
my_club: La mia società
my_team: La mia squadra
login: Accedi
logout: Esci
signup: Registra squadra
footer:
tagline: Ogni partita, ogni evento, per chi non può esserci
live: Dirette
manage_cookies: Gestisci cookie
copyright: "© 2026 Emiliano Frascaro P. IVA 14230270960"
responsibility: I contenuti trasmessi sono di esclusiva responsabilità delle società sportive che li pubblicano.
cookie:
title: Cookie e privacy
body_html: Usiamo cookie necessari per login e sicurezza. Con il tuo consenso attiviamo anche <strong>Google Analytics</strong> per statistiche aggregate sul sito. %{cookie_link} e %{privacy_link}.
policy: Cookie policy
privacy: Privacy
reject: Solo necessari
accept_all: Accetta tutti
meta:
default_title: Match Live TV — Diretta live partite giovanili
default_description: "Diretta live delle partite giovanili dal telefono: genitori e nonni guardano dal browser, senza app. Archivio partite per non perderle."
og_image_alt: Match Live TV — diretta live partite giovanili da smartphone
home:
title: Match Live TV — Diretta live partite giovanili da telefono
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."
schema_description: Piattaforma per dirette live e archivio partite delle società sportive giovanili.
headline_html: Ogni partita, ogni evento, per chi <span class="hero-accent">non può esserci.</span>
tagline: 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.
cta_signup: Registra la tua squadra
cta_live: Guarda le dirette
features_aria: Vantaggi principali
feature_stable: Dirette stabili
feature_archive: Archivio sicuro
feature_share: Condividi con chi vuoi
feature_phone: Tutto dal tuo telefono
hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
how_title: Come funziona lo streaming per squadre giovanili
step1_title: Registra la squadra
step1_body: La società si iscrive sul sito e sceglie il piano più adatto.
step2_title: Invita chi trasmette
step2_body: "Coach e volontari ricevono un invito: ognuno accede con la propria email."
step3_title: Vai in diretta
step3_body: "Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie."
step4_title: Tutti guardano da casa
step4_body: Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.
plans_title: Piani per dirette live e archivio partite
plans_lead: Inizia gratis. Passa a Premium quando vuoi più partite in contemporanea, archivio più lungo e diretta anche su YouTube.
plans_alt: "Ecosistema Match Live TV: smartphone in diretta, laptop con live e statistiche, tablet con archivio partite e cloud"
plans_cta: Confronta i piani
seo_title: "Streaming partite giovanili: perché le famiglie scelgono Match Live TV"
seo_p1_html: "Allenatori e dirigenti cercano un modo semplice per mandare in <strong>diretta live</strong> le partite di <strong>pallavolo giovanile</strong>, 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 <strong>Under 14, Under 16, Under 18</strong> e settori giovanili a chi è lontano."
seo_p2_html: "Un genitore che lavora, un nonno che non può viaggiare, un parente allestero: aprono un link e seguono la partita dal browser. Se arrivano in ritardo, la <strong>registrazione in archivio</strong> (con i piani Premium) resta disponibile per giorni. Niente account complicati per chi guarda — solo per lo staff che trasmette."
seo_p3_html: "Hai dubbi su come funziona? Leggi le %{faq_link}, scopri %{volleyball_link} o %{signup_link}."
seo_faq_link: domande frequenti
seo_volleyball_link: Match Live TV per la pallavolo giovanile
seo_signup_link: registra la squadra gratis
auth:
login_title: Accedi
email: Email
password: Password
submit_login: Accedi
forgot_password: Password dimenticata?
no_account: Non hai un account?
signup_link: Registra la squadra
signup_title: Registra la tua squadra
common:
privacy: Privacy
cookies: Cookie
terms: Termini
pricing: Prezzi
faq: FAQ
save: Salva
cancel: Annulla
delete: Elimina
confirm: Conferma
loading: Caricamento…
error: Si è verificato un errore
back: Indietro
+4
View File
@@ -123,6 +123,9 @@ Rails.application.routes.draw do
get "live", to: "public/live#index", as: :public_live_index
get "live/:id", to: "public/live#show", as: :public_live
get "live/:id/status.json", to: "public/live#status", as: :public_live_status
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 "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
@@ -154,6 +157,7 @@ Rails.application.routes.draw do
get "login", to: "sessions#new"
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy"
patch "locale", to: "locales#update", as: :locale
get "password/forgot", to: "password_resets#new", as: :password_forgot
post "password/forgot", to: "password_resets#create"
get "password/reset", to: "password_resets#edit", as: :password_reset
@@ -0,0 +1,29 @@
class AddPublicPageFieldsToTeams < ActiveRecord::Migration[7.2]
def up
add_column :teams, :slug, :string
add_column :teams, :roster_public, :boolean, default: false, null: false
Team.reset_column_information
used = {}
Team.find_each do |team|
base = team.name.to_s.parameterize.presence || "squadra"
slug = base
n = 2
while used[slug]
slug = "#{base}-#{n}"
n += 1
end
used[slug] = true
team.update_columns(slug: slug)
end
change_column_null :teams, :slug, false
add_index :teams, :slug, unique: true
end
def down
remove_index :teams, :slug
remove_column :teams, :roster_public
remove_column :teams, :slug
end
end
+4 -1
View File
@@ -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_06_11_120000) do
ActiveRecord::Schema[7.2].define(version: 2026_06_12_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -353,7 +353,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_06_11_120000) do
t.string "primary_color"
t.string "secondary_color"
t.text "description"
t.string "slug", null: false
t.boolean "roster_public", default: false, null: false
t.index ["club_id"], name: "index_teams_on_club_id"
t.index ["slug"], name: "index_teams_on_slug", unique: true
end
create_table "user_teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+162
View File
@@ -0,0 +1,162 @@
.site-confirm {
position: fixed;
inset: 0;
z-index: 10050;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.site-confirm[hidden] {
display: none !important;
}
.site-confirm__backdrop {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.72);
backdrop-filter: blur(4px);
}
.site-confirm__panel {
position: relative;
z-index: 1;
width: min(100%, 420px);
padding: 24px 24px 20px;
border-radius: 14px;
border: 1px solid #2a2a36;
background: #16161e;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
color: #f2f2f5;
}
.site-confirm__eyebrow {
margin: 0 0 8px;
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #e53935;
}
.site-confirm__title {
margin: 0 0 12px;
font-size: 1.25rem;
line-height: 1.25;
color: #fff;
}
.site-confirm__message {
margin: 0 0 22px;
color: #b8b8c4;
font-size: 0.95rem;
line-height: 1.55;
white-space: pre-line;
}
.site-confirm__panel--choices {
width: min(100%, 460px);
}
.site-confirm__choices {
display: flex;
flex-direction: column;
gap: 8px;
margin: 0 0 16px;
}
.site-confirm__choices[hidden] {
display: none !important;
}
.site-confirm__choice {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
width: 100%;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #3a3a48;
background: #1c1c26;
color: #f2f2f5;
text-align: left;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease;
}
.site-confirm__choice:hover,
.site-confirm__choice:focus-visible {
border-color: #6a6a7a;
background: #22222e;
outline: none;
}
.site-confirm__choice--danger {
border-color: rgba(229, 57, 53, 0.45);
background: rgba(229, 57, 53, 0.12);
}
.site-confirm__choice--danger:hover,
.site-confirm__choice--danger:focus-visible {
border-color: #e53935;
background: rgba(229, 57, 53, 0.2);
}
.site-confirm__choice--disabled,
.site-confirm__choice:disabled {
opacity: 0.45;
cursor: not-allowed;
pointer-events: none;
}
.site-confirm__choice[hidden] {
display: none !important;
}
.site-confirm__choice-label {
font-weight: 700;
font-size: 0.95rem;
}
.site-confirm__choice-hint {
font-size: 0.8rem;
color: #9a9aaa;
}
.site-confirm__actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: flex-end;
}
.site-confirm__btn[hidden] {
display: none !important;
}
.site-confirm__btn {
min-width: 110px;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 10px 16px;
border-radius: 8px;
font-weight: 700;
font-size: 0.9rem;
border: 1px solid transparent;
cursor: pointer;
text-decoration: none;
}
.site-confirm__btn.btn-secondary,
.site-confirm__btn[data-confirm-cancel] {
background: #22222c;
border-color: #3a3a48;
color: #eee;
}
.site-confirm__btn.btn-secondary:hover,
.site-confirm__btn[data-confirm-cancel]:hover {
border-color: #555;
}
.site-confirm__btn--danger {
background: #e53935 !important;
border-color: #e53935 !important;
color: #fff !important;
}
.site-confirm__btn--danger:hover {
filter: brightness(1.08);
}
body.site-confirm-open {
overflow: hidden;
}
@media (max-width: 480px) {
.site-confirm__actions {
flex-direction: column-reverse;
}
.site-confirm__btn {
width: 100%;
}
}
+311
View File
@@ -0,0 +1,311 @@
(function () {
var pendingForm = null;
var pendingScope = null;
var dialog = null;
function ensureDialog() {
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.className = "site-confirm";
dialog.setAttribute("role", "dialog");
dialog.setAttribute("aria-modal", "true");
dialog.setAttribute("aria-labelledby", "site-confirm-title");
dialog.hidden = true;
dialog.innerHTML =
'<div class="site-confirm__backdrop" data-confirm-cancel></div>' +
'<div class="site-confirm__panel site-confirm__panel--choices">' +
' <p class="site-confirm__eyebrow">Conferma richiesta</p>' +
' <h2 class="site-confirm__title" id="site-confirm-title">Sei sicuro?</h2>' +
' <p class="site-confirm__message" id="site-confirm-message"></p>' +
' <div class="site-confirm__choices" id="site-confirm-choices" hidden>' +
' <button type="button" class="site-confirm__choice" data-confirm-scope="site">' +
" <span class=\"site-confirm__choice-label\">Solo dal sito</span>" +
" <span class=\"site-confirm__choice-hint\" data-hint-site>Il video resta su YouTube</span>" +
" </button>" +
' <button type="button" class="site-confirm__choice" data-confirm-scope="youtube">' +
" <span class=\"site-confirm__choice-label\">Solo da YouTube</span>" +
" <span class=\"site-confirm__choice-hint\" data-hint-youtube>Resta disponibile sul sito</span>" +
" </button>" +
' <button type="button" class="site-confirm__choice site-confirm__choice--danger" data-confirm-scope="both">' +
" <span class=\"site-confirm__choice-label\">Da sito e YouTube</span>" +
" <span class=\"site-confirm__choice-hint\" data-hint-both>Eliminazione completa</span>" +
" </button>" +
" </div>" +
' <div class="site-confirm__actions">' +
' <button type="button" class="btn btn-secondary site-confirm__btn" data-confirm-cancel>Annulla</button>' +
' <button type="button" class="btn btn-primary site-confirm__btn site-confirm__btn--danger" data-confirm-ok>Elimina</button>' +
" </div>" +
"</div>";
document.body.appendChild(dialog);
dialog.addEventListener("click", function (event) {
var target = event.target;
if (!(target instanceof Element)) return;
if (target.closest("[data-confirm-cancel]")) {
closeDialog();
return;
}
var scopeBtn = target.closest("[data-confirm-scope]");
if (scopeBtn) {
if (scopeBtn.disabled || scopeBtn.getAttribute("aria-disabled") === "true") return;
submitPending(scopeBtn.getAttribute("data-confirm-scope"));
return;
}
if (target.closest("[data-confirm-ok]")) {
submitPending(pendingScope || "both");
}
});
document.addEventListener("keydown", function (event) {
if (!dialog || dialog.hidden) return;
if (event.key === "Escape") {
event.preventDefault();
closeDialog();
}
});
return dialog;
}
function setChoiceVisible(btn, visible) {
if (!(btn instanceof HTMLElement)) return;
btn.hidden = !visible;
if (!visible) {
btn.disabled = true;
btn.setAttribute("aria-disabled", "true");
}
}
function setChoiceEnabled(btn, enabled) {
if (!(btn instanceof HTMLButtonElement)) return;
btn.disabled = !enabled;
btn.setAttribute("aria-disabled", enabled ? "false" : "true");
btn.classList.toggle("site-confirm__choice--disabled", !enabled);
}
function openDialog(message, options) {
options = options || {};
var hasSite = options.hasSite !== false;
var hasYoutube = !!options.hasYoutube;
var canSite = hasSite;
var canYoutube = hasYoutube;
var canBoth = hasSite && hasYoutube;
var choiceCount = (canSite ? 1 : 0) + (canYoutube ? 1 : 0) + (canBoth ? 1 : 0);
// Una sola destinazione disponibile → conferma semplice, senza elenco
var multi = !!options.multi && choiceCount > 1;
if (canBoth) pendingScope = "both";
else if (canSite) pendingScope = "site";
else if (canYoutube) pendingScope = "youtube";
else pendingScope = "both";
var el = ensureDialog();
var titleEl = el.querySelector("#site-confirm-title");
var messageEl = el.querySelector("#site-confirm-message");
var okBtn = el.querySelector("[data-confirm-ok]");
var choicesEl = el.querySelector("#site-confirm-choices");
var siteBtn = el.querySelector('[data-confirm-scope="site"]');
var youtubeBtn = el.querySelector('[data-confirm-scope="youtube"]');
var bothBtn = el.querySelector('[data-confirm-scope="both"]');
var hintSite = el.querySelector("[data-hint-site]");
var hintYoutube = el.querySelector("[data-hint-youtube]");
var hintBoth = el.querySelector("[data-hint-both]");
var panel = el.querySelector(".site-confirm__panel");
if (titleEl) {
if (multi) {
titleEl.textContent = "Cosa vuoi eliminare?";
} else if (pendingScope === "youtube") {
titleEl.textContent = "Eliminare da YouTube?";
} else if (pendingScope === "site") {
titleEl.textContent = "Eliminare dal sito?";
} else {
titleEl.textContent = /elimin/i.test(message)
? "Eliminare definitivamente?"
: "Confermi questa operazione?";
}
}
if (messageEl) {
if (multi) {
messageEl.textContent =
"Scegli dove rimuovere il replay. L'operazione è irreversibile.";
} else if (pendingScope === "youtube") {
messageEl.textContent =
"Il video verrà rimosso da YouTube. Il replay resterà disponibile sul sito.";
} else if (pendingScope === "site") {
messageEl.textContent = hasYoutube
? "Il replay verrà rimosso dal sito. Il video su YouTube resterà online."
: "Stai per eliminare definitivamente questo replay dal sito. L'operazione è irreversibile.";
} else {
messageEl.textContent = message;
}
}
if (choicesEl) choicesEl.hidden = !multi;
if (panel) panel.classList.toggle("site-confirm__panel--choices", multi);
if (okBtn instanceof HTMLElement) {
okBtn.hidden = multi;
if (pendingScope === "youtube") okBtn.textContent = "Elimina da YouTube";
else if (pendingScope === "site") okBtn.textContent = "Elimina dal sito";
else okBtn.textContent = /elimin/i.test(message) ? "Elimina" : "Conferma";
}
setChoiceVisible(siteBtn, canSite);
setChoiceVisible(youtubeBtn, canYoutube);
setChoiceVisible(bothBtn, canBoth);
if (multi) {
setChoiceEnabled(siteBtn, canSite);
setChoiceEnabled(youtubeBtn, canYoutube);
setChoiceEnabled(bothBtn, canBoth);
if (hintSite) {
hintSite.textContent = canYoutube
? "Il video resta su YouTube"
: "Rimuove i file dal sito";
}
if (hintYoutube) {
hintYoutube.textContent = canSite
? "Resta disponibile sul sito"
: "Rimuove solo il video YouTube";
}
if (hintBoth) hintBoth.textContent = "Eliminazione completa";
}
el.hidden = false;
document.body.classList.add("site-confirm-open");
if (multi) {
var focusBtn = canSite ? siteBtn : canYoutube ? youtubeBtn : bothBtn;
if (focusBtn instanceof HTMLElement) focusBtn.focus();
} else if (okBtn instanceof HTMLElement) {
okBtn.focus();
}
}
function closeDialog() {
if (!dialog) return;
dialog.hidden = true;
document.body.classList.remove("site-confirm-open");
pendingForm = null;
pendingScope = null;
}
function ensureHiddenInput(form, name, value) {
var input = form.querySelector('input[name="' + name + '"]');
if (!(input instanceof HTMLInputElement)) {
input = document.createElement("input");
input.type = "hidden";
input.name = name;
form.appendChild(input);
}
input.value = value;
}
function submitPending(scope) {
var form = pendingForm;
if (form && form.closest(".replay-archive")) {
saveArchiveScrollPosition();
}
closeDialog();
if (!form) return;
ensureHiddenInput(form, "delete_scope", scope || "both");
form.dataset.confirmAccepted = "1";
if (typeof form.requestSubmit === "function") {
form.requestSubmit();
} else {
form.submit();
}
}
document.addEventListener(
"submit",
function (event) {
var form = event.target;
if (!(form instanceof HTMLFormElement)) return;
if (form.dataset.confirmAccepted === "1") {
delete form.dataset.confirmAccepted;
return;
}
var message =
form.getAttribute("data-confirm") ||
form.getAttribute("data-turbo-confirm");
if (!message) return;
event.preventDefault();
event.stopImmediatePropagation();
pendingForm = form;
pendingScope = "both";
var mode = form.getAttribute("data-confirm-mode") || "";
var isReplayDelete =
mode === "delete-replay" || mode === "delete-replay-youtube";
var hasYoutube =
form.getAttribute("data-has-youtube") === "1" ||
mode === "delete-replay-youtube";
var hasSiteAttr = form.getAttribute("data-has-site");
var hasSite = hasSiteAttr == null ? true : hasSiteAttr === "1";
openDialog(message, {
multi: isReplayDelete,
hasYoutube: hasYoutube,
hasSite: hasSite
});
},
true
);
// Dopo submit nell'archivio replay, resta alla stessa altezza di scroll
// (il redirect ricarica la pagina e altrimenti torna in cima).
var SCROLL_KEY = "matchlivetv.replayArchive.scrollY";
function saveArchiveScrollPosition() {
try {
sessionStorage.setItem(
SCROLL_KEY,
String(window.scrollY || window.pageYOffset || 0)
);
} catch (err) {
/* ignore */
}
}
function saveArchiveScroll(event) {
var form = event.target;
if (!(form instanceof HTMLFormElement)) return;
if (!form.closest(".replay-archive")) return;
var needsConfirm =
form.getAttribute("data-confirm") ||
form.getAttribute("data-turbo-confirm");
// Con confirm lo scroll si salva in submitPending, dopo la scelta nel dialog
if (needsConfirm) return;
saveArchiveScrollPosition();
}
function restoreArchiveScroll() {
try {
var raw = sessionStorage.getItem(SCROLL_KEY);
if (raw == null) return;
sessionStorage.removeItem(SCROLL_KEY);
var top = parseInt(raw, 10);
if (isNaN(top)) return;
window.scrollTo(0, top);
requestAnimationFrame(function () {
window.scrollTo(0, top);
});
} catch (err) {
/* ignore */
}
}
document.addEventListener("submit", saveArchiveScroll, true);
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", restoreArchiveScroll);
} else {
restoreArchiveScroll();
}
})();
+399 -8
View File
@@ -27,7 +27,98 @@ a:hover { text-decoration: underline; }
padding-top: 14px;
padding-bottom: 14px;
}
.mast-inner .nav-toggle { margin-left: auto; }
.mast-tools {
margin-left: auto;
display: flex;
align-items: center;
gap: 10px;
}
.mast-inner .nav-toggle { margin-left: 0; }
.nav-lang {
margin-left: 4px;
display: flex;
align-items: center;
}
.lang-switcher {
position: relative;
display: inline-flex;
align-items: center;
}
.lang-switcher__toggle {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
min-width: 42px;
height: 36px;
padding: 0 8px;
border-radius: 8px;
border: 1px solid #3a3a48;
background: #1c1c26;
color: #f0f0f5;
cursor: pointer;
line-height: 1;
}
.lang-switcher__toggle:hover,
.lang-switcher.is-open .lang-switcher__toggle {
border-color: #6a6a7a;
background: #262632;
}
.lang-switcher__flag {
font-size: 1.2rem;
line-height: 1;
}
.lang-switcher__chevron {
width: 0;
height: 0;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid #9a9aaa;
margin-left: 2px;
}
.lang-switcher__menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 1400;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 48px;
padding: 6px;
border-radius: 10px;
border: 1px solid #3a3a48;
background: #16161e;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.45);
}
.lang-switcher__menu[hidden] {
display: none !important;
}
.lang-switcher__option-form {
margin: 0;
display: block;
}
.lang-switcher__option {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-height: 36px;
padding: 4px 8px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
cursor: pointer;
color: #fff;
}
.lang-switcher__option:hover {
background: #22222e;
border-color: #3a3a48;
}
.lang-switcher__option.is-active {
background: rgba(229, 57, 53, 0.15);
border-color: rgba(229, 57, 53, 0.4);
}
.brand { font-weight: 800; font-size: 1.15rem; letter-spacing: 0.03em; color: #fff; flex-shrink: 0; }
.brand span { color: #e53935; }
.mast-brand {
@@ -197,6 +288,12 @@ body.nav-menu-open { overflow: hidden; }
font-size: 1.05rem !important;
border-radius: 10px;
}
.nav-lang {
margin-left: 0;
margin-top: 4px;
justify-content: flex-end;
width: 100%;
}
}
@media (min-width: 900px) {
@@ -248,6 +345,10 @@ body.nav-menu-open { overflow: hidden; }
.nav-actions {
flex-wrap: nowrap;
gap: 8px 12px;
margin-left: auto;
}
.nav-lang {
margin-left: 2px;
}
.nav-backdrop { display: none !important; }
}
@@ -466,6 +567,251 @@ body.nav-menu-open { overflow: hidden; }
color: #888;
font-size: 0.9rem;
}
.team-public-page { padding-top: 20px; padding-bottom: 48px; }
.team-public-hero { margin-bottom: 28px; }
.team-public-meta {
margin: 0 0 12px;
color: #aaa;
font-size: 0.92rem;
}
.team-public-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 18px;
}
.team-public-section { margin-bottom: 32px; }
.team-public-more { margin-top: 16px; }
.team-public-more a { color: #e53935; text-decoration: none; }
.team-public-more a:hover { text-decoration: underline; }
.team-public-roster .roster-section { margin-bottom: 16px; }
.team-public-empty { margin-top: 24px; }
.team-public-empty { margin-top: 24px; }
.team-directory { padding-top: 20px; padding-bottom: 48px; }
.team-directory-filters { margin: 20px 0 16px; }
.team-directory-filters__search { margin-bottom: 16px; }
.team-directory-filters__row {
display: flex;
flex-wrap: wrap;
gap: 16px 20px;
align-items: flex-start;
}
.team-directory-filters__field {
display: flex;
flex-direction: column;
min-width: 0;
}
.team-directory-filters__field--chips {
flex: 1 1 auto;
}
.team-directory-filters__label {
display: block;
margin: 0 0 8px;
font-size: 0.82rem;
font-weight: 500;
color: #9a9aaa;
line-height: 1.2;
min-height: 1.2em;
}
.team-directory-filters__label--spacer {
visibility: hidden;
}
.team-directory-filters__select {
width: 100%;
max-width: 240px;
height: 44px;
margin: 0;
padding: 0 14px;
border-radius: 10px;
border: 1px solid #34344a;
background: #14141c;
color: #fff;
font-size: 0.95rem;
box-sizing: border-box;
}
.team-directory-filters__select:focus {
outline: none;
border-color: #e53935;
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
}
.team-directory-chips {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
min-height: 44px;
}
.filter-chip {
position: relative;
display: inline-flex;
cursor: pointer;
user-select: none;
margin: 0;
}
.filter-chip__input {
position: absolute;
opacity: 0;
width: 1px;
height: 1px;
margin: 0;
pointer-events: none;
}
.filter-chip__face {
display: inline-flex;
align-items: center;
gap: 8px;
height: 44px;
box-sizing: border-box;
padding: 0 16px;
border-radius: 999px;
border: 1px solid #34344a;
background: #14141c;
color: #b8b8c8;
font-size: 0.9rem;
font-weight: 500;
letter-spacing: 0.01em;
transition: border-color 0.15s, background 0.15s, color 0.15s, box-shadow 0.15s;
}
.filter-chip:hover .filter-chip__face {
border-color: #4a4a62;
color: #eee;
}
.filter-chip__input:focus-visible + .filter-chip__face {
outline: 2px solid rgba(229, 57, 53, 0.55);
outline-offset: 2px;
}
.filter-chip.is-active .filter-chip__face,
.filter-chip__input:checked + .filter-chip__face {
border-color: rgba(229, 57, 53, 0.75);
background: rgba(229, 57, 53, 0.14);
color: #fff;
box-shadow: inset 0 0 0 1px rgba(229, 57, 53, 0.2);
}
.filter-chip__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #666;
flex-shrink: 0;
}
.filter-chip__dot--live {
background: #e53935;
box-shadow: 0 0 8px rgba(229, 57, 53, 0.7);
}
.filter-chip.is-active .filter-chip__dot--live,
.filter-chip__input:checked + .filter-chip__face .filter-chip__dot--live {
animation: filter-chip-pulse 1.4s ease-in-out infinite;
}
.filter-chip__icon {
font-size: 0.7rem;
opacity: 0.85;
line-height: 1;
}
@keyframes filter-chip-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.55; transform: scale(0.85); }
}
.team-directory-filters__actions {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.team-directory-filters__reset {
height: 44px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
margin: 0;
}
.team-directory-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px;
margin-top: 20px;
}
.team-directory-card {
display: flex;
gap: 14px;
padding: 16px;
background: #16161e;
border: 1px solid #2a2a36;
border-radius: 12px;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, transform 0.15s;
}
.team-directory-card:hover {
border-color: #e53935;
transform: translateY(-1px);
}
.team-directory-card__media { flex-shrink: 0; }
.team-directory-card__photo {
width: 72px;
height: 72px;
border-radius: 10px;
object-fit: cover;
display: block;
}
.team-directory-card__photo--logo {
object-fit: contain;
background: #111;
padding: 8px;
}
.team-directory-card__photo--fallback {
display: flex;
align-items: center;
justify-content: center;
background: #1f1f28;
color: #e53935;
font-weight: 700;
font-size: 1.25rem;
}
.team-directory-card__body { min-width: 0; }
.team-directory-card__club {
margin: 0 0 4px;
font-size: 0.78rem;
color: #888;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.team-directory-card__name {
margin: 0 0 6px;
font-size: 1.05rem;
color: #fff;
line-height: 1.25;
}
.team-directory-card__meta {
margin: 0 0 10px;
font-size: 0.85rem;
color: #999;
}
.team-directory-card__badges {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.team-directory-footer {
margin-top: 28px;
font-size: 0.9rem;
}
.team-directory-footer a { color: #e53935; text-decoration: none; }
.team-directory-footer a:hover { text-decoration: underline; }
.roster-hero__photo--logo { object-fit: contain; background: #111; padding: 16px; }
.team-public-logo-fallback {
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
font-weight: 700;
color: var(--club-primary, #e53935);
}
.live-card__team-link {
color: inherit;
text-decoration: underline;
text-decoration-color: rgba(229, 57, 53, 0.45);
text-underline-offset: 2px;
}
.live-card__team-link:hover { color: #fff; text-decoration-color: #e53935; }
.team-streaming-staff {
margin-top: 36px;
padding-top: 28px;
@@ -1395,11 +1741,11 @@ body.cookie-banner-visible {
}
.replay-archive-table__col-status {
width: 18%;
width: 14%;
}
.replay-archive-table__col-actions {
width: 12%;
width: 16%;
}
.replay-archive__detail-line {
@@ -1418,7 +1764,7 @@ body.cookie-banner-visible {
}
.replay-archive-table__col-status .replay-archive__status {
margin-bottom: 6px;
margin-bottom: 0;
}
.replay-archive__thumb {
@@ -1483,7 +1829,13 @@ a.replay-archive__thumb:hover {
.replay-archive__privacy-form,
.replay-archive__action-form {
margin: 0;
display: inline-flex;
align-items: center;
}
.replay-archive__title-form {
display: block;
width: 100%;
}
.replay-archive__title-input {
@@ -1493,11 +1845,50 @@ a.replay-archive__thumb:hover {
font-size: 0.88rem;
}
.replay-archive__privacy-select {
width: 100%;
.replay-archive__privacy-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
margin: 0;
padding: 6px 8px;
font-size: 0.8rem;
padding: 0;
border-radius: 8px;
border: 1px solid #3a3a48;
background: #1c1c26;
color: #c8c8d4;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.replay-archive__privacy-toggle:hover {
border-color: #6a6a7a;
background: #262632;
color: #fff;
}
.replay-archive__privacy-toggle--public {
color: #81c784;
border-color: rgba(129, 199, 132, 0.35);
background: rgba(76, 175, 80, 0.12);
}
.replay-archive__privacy-toggle--public:hover {
border-color: #81c784;
background: rgba(76, 175, 80, 0.2);
color: #a5d6a7;
}
.replay-archive__privacy-toggle--unlisted {
color: #ffca28;
border-color: rgba(255, 202, 40, 0.35);
background: rgba(255, 193, 7, 0.1);
}
.replay-archive__privacy-toggle--unlisted:hover {
border-color: #ffca28;
background: rgba(255, 193, 7, 0.18);
color: #ffe082;
}
.replay-archive__privacy-icon {
width: 18px;
height: 18px;
display: block;
}
.replay-archive__status {
@@ -0,0 +1,70 @@
require "rails_helper"
RSpec.describe "Public team page", type: :request do
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) do
club.teams.create!(
name: "Tigers Volley U17",
sport_key: "pallavolo",
description: "Squadra giovanile under 17.",
roster_public: true
)
end
it "è accessibile senza login" do
get public_team_page_path(team.slug)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Tigers Volley U17")
expect(response.body).to include("ASD Eagles")
expect(response.body).to include("Squadra giovanile under 17")
end
it "restituisce 404 per slug inesistente" do
get public_team_page_path("squadra-inesistente")
expect(response).to have_http_status(:not_found)
end
it "mostra l'organico solo se roster_public è attivo" do
team.roster_members.create!(full_name: "Mario Rossi", category: "player", jersey_number: 7)
get public_team_page_path(team.slug)
expect(response.body).to include("Mario Rossi")
team.update!(roster_public: false)
get public_team_page_path(team.slug)
expect(response.body).not_to include("Mario Rossi")
expect(response.body).not_to include("Organico")
end
end
RSpec.describe "Public team directory", type: :request do
let(:club) { Club.create!(name: "ASD Eagles", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let(:user) { User.create!(email: "dir2@test.com", name: "Coach", password: "password123", role: "coach") }
let!(:team) { club.teams.create!(name: "Tigers Volley U17", sport_key: "pallavolo") }
before do
club.club_memberships.create!(user: user, role: "owner")
match = team.matches.create!(opponent_name: "Rival", sport_key: "pallavolo", scheduled_at: 1.day.from_now)
StreamSession.create!(
match: match, user: user, status: "live", platform: "matchlivetv",
privacy_status: "public", publish_token: "tok-dir", started_at: Time.current
)
end
it "elenca le squadre attive senza login" do
get public_team_pages_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Tigers Volley U17")
expect(response.body).to include("In diretta")
end
it "filtra per sport via query string" do
get public_team_pages_path(sport: "basket")
expect(response.body).not_to include("Tigers Volley U17")
end
end
@@ -0,0 +1,23 @@
require "rails_helper"
RSpec.describe LocaleResolver do
describe ".normalize" do
it "accetta lingue supportate" do
expect(described_class.normalize("en")).to eq(:en)
expect(described_class.normalize("fr-FR")).to eq(:fr)
expect(described_class.normalize("de_DE")).to eq(:de)
end
it "rifiuta lingue non supportate" do
expect(described_class.normalize("pt")).to be_nil
expect(described_class.normalize("")).to be_nil
end
end
describe ".from_accept_language" do
it "prende la prima lingua supportata" do
expect(described_class.from_accept_language("fr-FR,fr;q=0.9,en;q=0.8")).to eq(:fr)
expect(described_class.from_accept_language("pt-BR,en-US;q=0.8")).to eq(:en)
end
end
end
@@ -16,15 +16,21 @@ RSpec.describe Recordings::Delete do
privacy_status: "public",
expires_at: 10.days.from_now,
storage_key: "teams/#{team.id}/sessions/#{session.id}/replay.mp4",
youtube_video_id: "abc123xyz"
youtube_video_id: "abc123xyz",
youtube_published_at: Time.current
)
end
it "elimina storage e revoca youtube_video_id" do
def stub_delete_deps
storage = instance_double(Recordings::Storage, delete: true)
allow(Recordings::Storage).to receive(:new).and_return(storage)
yt = instance_double(Youtube::VideoLifecycleService, delete!: true)
allow(Youtube::VideoLifecycleService).to receive(:new).with(recording).and_return(yt)
[storage, yt]
end
it "elimina storage e YouTube (scope both)" do
storage, yt = stub_delete_deps
described_class.new(recording).call
@@ -35,4 +41,32 @@ RSpec.describe Recordings::Delete do
expect(recording.youtube_video_id).to be_nil
expect(recording.storage_key).to be_nil
end
it "elimina solo dal sito lasciando YouTube" do
storage, yt = stub_delete_deps
described_class.new(recording, scope: :site).call
expect(yt).not_to have_received(:delete!)
expect(storage).to have_received(:delete).at_least(:once)
recording.reload
expect(recording.deleted_at).to be_present
expect(recording.storage_key).to be_nil
expect(recording.youtube_video_id).to eq("abc123xyz")
end
it "elimina solo da YouTube lasciando il replay sul sito" do
storage, yt = stub_delete_deps
described_class.new(recording, scope: :youtube).call
expect(yt).to have_received(:delete!)
expect(storage).not_to have_received(:delete)
recording.reload
expect(recording.deleted_at).to be_nil
expect(recording.status).to eq("ready")
expect(recording.storage_key).to be_present
expect(recording.youtube_video_id).to be_nil
expect(recording.youtube_published_at).to be_nil
end
end
@@ -0,0 +1,16 @@
require "rails_helper"
RSpec.describe Teams::GenerateSlug do
let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
it "genera uno slug dal nome" do
team = club.teams.build(name: "Crazy Volley U17B", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("crazy-volley-u17b")
end
it "aggiunge un suffisso se lo slug esiste già" do
club.teams.create!(name: "Tigers", sport_key: "pallavolo")
team = club.teams.build(name: "Tigers", sport_key: "pallavolo")
expect(described_class.call(team)).to eq("tigers-2")
end
end
@@ -0,0 +1,84 @@
require "rails_helper"
RSpec.describe Teams::PublicDirectory do
let(:user) { User.create!(email: "dir-#{SecureRandom.hex(4)}@test.com", name: "Dir", password: "password123", role: "coach") }
let(:club) { Club.create!(name: "ASD Directory #{SecureRandom.hex(3)}", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team_live) { club.teams.create!(name: "Live Team", sport_key: "pallavolo") }
let!(:team_replay) { club.teams.create!(name: "Replay Team", sport_key: "basket") }
let!(:team_upcoming) { club.teams.create!(name: "Future Team", sport_key: "pallavolo") }
let!(:team_empty) { club.teams.create!(name: "Empty Team", sport_key: "pallavolo") }
before do
club.club_memberships.create!(user: user, role: "owner")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
live_match = team_live.matches.create!(opponent_name: "Rival A", sport_key: "pallavolo", scheduled_at: 1.hour.from_now)
StreamSession.create!(
match: live_match, user: user, status: "live", platform: "matchlivetv",
privacy_status: "public", publish_token: "tok-live", started_at: Time.current
)
replay_match = team_replay.matches.create!(opponent_name: "Rival B", sport_key: "basket")
replay_session = StreamSession.create!(
match: replay_match, user: user, status: "ended", platform: "matchlivetv",
privacy_status: "public", publish_token: "tok-replay", started_at: 2.days.ago, ended_at: 1.day.ago
)
team_replay.recordings.create!(
stream_session: replay_session,
team: team_replay,
status: "ready",
privacy_status: "public",
storage_backend: "local",
storage_key: "teams/#{team_replay.id}/test.mp4",
recorded_at: 1.day.ago
)
team_upcoming.matches.create!(
opponent_name: "Rival C",
sport_key: "pallavolo",
scheduled_at: 2.days.from_now,
location: "VenueUnique42"
)
end
it "include solo squadre con presenza pubblica" do
ids = club_entries.map { |e| e.team.id }
expect(ids).to include(team_live.id, team_replay.id, team_upcoming.id)
expect(ids).not_to include(team_empty.id)
end
it "filtra per sport" do
ids = club_entries(sport: "basket").map { |e| e.team.id }
expect(ids).to contain_exactly(team_replay.id)
end
it "filtra per testo libero" do
ids = club_entries(q: "VenueUnique42").map { |e| e.team.id }
expect(ids).to contain_exactly(team_upcoming.id)
end
it "filtra per diretta in corso" do
ids = club_entries(live: true).map { |e| e.team.id }
expect(ids).to contain_exactly(team_live.id)
end
it "filtra per replay" do
ids = club_entries(replays: true).map { |e| e.team.id }
expect(ids).to contain_exactly(team_replay.id)
end
it "ordina le dirette per prime" do
slugs = club_entries.map { |e| e.team.slug }
expect(slugs.first).to eq(team_live.slug)
end
def club_entries(**params)
described_class.call(**params).select { |e| e.team.club_id == club.id }
end
end
+5 -4
View File
@@ -14,14 +14,14 @@ if (keystorePropertiesFile.exists()) {
android {
namespace = "com.matchlivetv.match_live_tv"
compileSdk = 35
compileSdk = 36
defaultConfig {
applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24
targetSdk = 35
versionCode = 21
versionName = "2.0.0-native"
targetSdk = 36
versionCode = 23
versionName = "2.0.2-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it"
@@ -75,6 +75,7 @@ dependencies {
implementation(composeBom)
androidTestImplementation(composeBom)
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("androidx.core:core-ktx:1.15.0")
implementation("androidx.activity:activity-compose:1.9.3")
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.8.7")
@@ -21,6 +21,7 @@
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.MatchLiveTv"
android:localeConfig="@xml/locales_config"
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
@@ -29,7 +30,7 @@
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<intent-filter>
@@ -57,5 +58,15 @@
android:name=".streaming.LiveBroadcastService"
android:exported="false"
android:foregroundServiceType="camera|microphone" />
<!-- Persistenza lingue AppCompat (API < 33) -->
<service
android:name="androidx.appcompat.app.AppLocalesMetadataHolderService"
android:enabled="false"
android:exported="false">
<meta-data
android:name="autoStoreLocales"
android:value="true" />
</service>
</application>
</manifest>
@@ -1,10 +1,13 @@
package com.matchlivetv.match_live_tv
import android.content.Context
import android.content.res.Configuration
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.key
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.ui.navigation.AppNavHost
import com.matchlivetv.match_live_tv.ui.theme.MatchLiveTheme
@@ -12,6 +15,10 @@ class MainActivity : ComponentActivity() {
private val container by lazy { (application as MatchLiveTvApplication).container }
override fun attachBaseContext(newBase: Context) {
super.attachBaseContext(AppLocale.wrap(newBase))
}
override fun onConfigurationChanged(newConfig: Configuration) {
container.broadcastCoordinator.pauseForConfigurationChange()
super.onConfigurationChanged(newConfig)
@@ -21,9 +28,12 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
val localeTag = AppLocale.currentTag(this)
setContent {
MatchLiveTheme {
AppNavHost(container)
key(localeTag) {
MatchLiveTheme {
AppNavHost(container)
}
}
}
}
@@ -0,0 +1,89 @@
package com.matchlivetv.match_live_tv.core
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.content.res.Configuration
import android.os.Handler
import android.os.LocaleList
import android.os.Looper
import androidx.appcompat.app.AppCompatDelegate
import androidx.core.os.LocaleListCompat
import java.util.Locale
/**
* Lingua app persistita in SharedPreferences + context wrapping.
* Più affidabile di soli AppCompat locales su ComponentActivity/Compose.
*/
object AppLocale {
const val SYSTEM = ""
private const val PREFS = "mltv_locale"
private const val KEY_TAG = "tag"
data class Option(val tag: String, val labelRes: Int)
val options = listOf(
Option(SYSTEM, com.matchlivetv.match_live_tv.R.string.language_system),
Option("it", com.matchlivetv.match_live_tv.R.string.language_italian),
Option("en", com.matchlivetv.match_live_tv.R.string.language_english),
Option("fr", com.matchlivetv.match_live_tv.R.string.language_french),
Option("de", com.matchlivetv.match_live_tv.R.string.language_german),
Option("es", com.matchlivetv.match_live_tv.R.string.language_spanish),
)
fun normalize(tag: String): String {
if (tag.isBlank()) return SYSTEM
return tag.substringBefore(",").substringBefore("-").substringBefore("_").lowercase()
}
fun currentTag(context: Context): String {
val stored = context.applicationContext
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
.getString(KEY_TAG, SYSTEM)
.orEmpty()
return normalize(stored)
}
fun wrap(context: Context): Context {
val tag = currentTag(context)
if (tag.isBlank()) return context
val locale = Locale.forLanguageTag(tag)
Locale.setDefault(locale)
val config = Configuration(context.resources.configuration)
config.setLocales(LocaleList(locale))
return context.createConfigurationContext(config)
}
fun apply(activity: Activity, tag: String) {
val normalized = normalize(tag)
val prefs = activity.applicationContext.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
if (normalized == currentTag(activity)) return
// commit sincronizzato: deve essere scritto prima del recreate
prefs.edit().putString(KEY_TAG, normalized).commit()
val locales = if (normalized.isBlank()) {
LocaleListCompat.getEmptyLocaleList()
} else {
LocaleListCompat.forLanguageTags(normalized)
}
AppCompatDelegate.setApplicationLocales(locales)
activity.recreate()
}
fun applyAfterDismiss(activity: Activity?, tag: String, dismiss: () -> Unit) {
dismiss()
Handler(Looper.getMainLooper()).post {
if (activity == null || activity.isFinishing || activity.isDestroyed) return@post
apply(activity, tag)
}
}
}
tailrec fun Context.findActivity(): Activity? = when (this) {
is Activity -> this
is ContextWrapper -> baseContext.findActivity()
else -> null
}
@@ -0,0 +1,47 @@
package com.matchlivetv.match_live_tv.core
import java.net.URI
/**
* Normalizza l'URL RTMP restituito dall'API per il dispositivo corrente.
* In locale Docker espone spesso 127.0.0.1 / mediamtx: dall'emulatore Android
* vanno riscritti sull'host dell'API (es. 10.0.2.2).
*/
object RtmpIngestUrl {
private val rewriteHosts = setOf(
"localhost",
"127.0.0.1",
"mediamtx",
"host.docker.internal",
)
fun resolveForDevice(urlString: String): String =
resolve(urlString, apiBaseUrl = AppConfig.apiBaseUrl)
fun resolve(urlString: String, apiBaseUrl: String): String {
if (urlString.isBlank()) return urlString
val uri = runCatching { URI(urlString) }.getOrNull() ?: return urlString
val host = uri.host?.lowercase() ?: return urlString
if (host !in rewriteHosts) return urlString
val apiHost = runCatching { URI(apiBaseUrl).host }.getOrNull()
?.takeIf { it.isNotBlank() }
?: return urlString
val port = if (uri.port > 0) uri.port else 1935
val path = uri.rawPath.orEmpty()
val query = uri.rawQuery
return buildString {
append("rtmp://")
append(apiHost)
append(':')
append(port)
append(path)
if (!query.isNullOrBlank()) {
append('?')
append(query)
}
}
}
}
@@ -32,6 +32,7 @@ import androidx.compose.ui.viewinterop.AndroidView
import com.matchlivetv.match_live_tv.core.DeviceTelemetry
import com.matchlivetv.match_live_tv.core.parseColorHex
import com.matchlivetv.match_live_tv.core.resolveMediaUrl
import com.matchlivetv.match_live_tv.core.RtmpIngestUrl
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.MatchScoringRules
@@ -85,7 +86,10 @@ fun BroadcastScreen(
}
fun broadcastConfig(loaded: StreamSession): BroadcastConfig =
StreamVideoPreset.broadcastConfig(loaded, loaded.rtmpIngestUrl.orEmpty())
StreamVideoPreset.broadcastConfig(
loaded,
RtmpIngestUrl.resolveForDevice(loaded.rtmpIngestUrl.orEmpty()),
)
suspend fun stopStreamPermanently() {
runCatching { container.sessionRepository.stopSession(sessionId) }
@@ -0,0 +1,69 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.findActivity
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun LanguagePickerDialog(
onDismiss: () -> Unit,
) {
val context = LocalContext.current
val activity = context.findActivity()
val selected = AppLocale.currentTag(context)
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.language_label)) },
text = {
Column {
AppLocale.options.forEach { option ->
val optionTag = AppLocale.normalize(option.tag)
val isSelected = selected == optionTag
fun choose() {
AppLocale.applyAfterDismiss(activity, option.tag, onDismiss)
}
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = ::choose)
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Start,
) {
RadioButton(
selected = isSelected,
onClick = ::choose,
)
Text(
text = stringResource(option.labelRes),
color = MatchColors.TextSecondary,
modifier = Modifier.padding(start = 4.dp),
)
}
}
}
},
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
@@ -17,6 +17,7 @@ import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -27,13 +28,16 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
@@ -50,8 +54,12 @@ fun LoginScreen(
var error by remember { mutableStateOf<String?>(null) }
var loading by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
var showLanguagePicker by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val errCredentials = stringResource(R.string.login_error_credentials)
val errUnreachable = stringResource(R.string.login_error_unreachable)
val errGeneric = stringResource(R.string.login_error_generic)
fun submitLogin() {
if (loading || email.isBlank() || password.isBlank()) return
@@ -64,10 +72,9 @@ fun LoginScreen(
onLoggedIn()
}.onFailure {
error = when {
it.message?.contains("401") == true -> "Email o password non corretti"
it.message?.contains("timeout", ignoreCase = true) == true ->
"Server non raggiungibile. Verifica la connessione."
else -> it.message ?: "Login fallito"
it.message?.contains("401") == true -> errCredentials
it.message?.contains("timeout", ignoreCase = true) == true -> errUnreachable
else -> it.message ?: errGeneric
}
}
loading = false
@@ -75,6 +82,9 @@ fun LoginScreen(
}
MatchScreenScaffold {
if (showLanguagePicker) {
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
@@ -82,22 +92,20 @@ fun LoginScreen(
horizontalAlignment = Alignment.CenterHorizontally,
) {
MatchLiveWordmark(showSlogan = true)
Spacer(Modifier.height(48.dp))
Spacer(Modifier.height(16.dp))
TextButton(onClick = { showLanguagePicker = true }) {
Text(stringResource(R.string.language_label), color = MatchColors.TextSecondary)
}
Spacer(Modifier.height(32.dp))
Text(
text = "ACCEDI",
text = stringResource(R.string.login_submit).uppercase(),
style = androidx.compose.material3.MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(8.dp))
Text(
text = "Gestisci le dirette della tua squadra",
style = androidx.compose.material3.MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("Email") },
label = { Text(stringResource(R.string.login_email)) },
placeholder = { Text("coach@squadra.it") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
@@ -114,7 +122,7 @@ fun LoginScreen(
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("Password") },
label = { Text(stringResource(R.string.login_password)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (passwordVisible) {
@@ -153,7 +161,7 @@ fun LoginScreen(
}
Spacer(Modifier.height(32.dp))
MatchPrimaryButton(
label = "ACCEDI",
label = stringResource(R.string.login_submit).uppercase(),
loading = loading,
enabled = email.isNotBlank() && password.isNotBlank(),
onClick = { submitLogin() },
@@ -2,24 +2,30 @@ package com.matchlivetv.match_live_tv.ui.matches
import android.app.DatePickerDialog
import android.app.TimePickerDialog
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CalendarToday
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.ChevronRight
import androidx.compose.material.icons.filled.EventAvailable
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Groups
import androidx.compose.material.icons.filled.PlayCircleOutline
import androidx.compose.material.icons.filled.RadioButtonUnchecked
@@ -41,10 +47,15 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.isScheduledFuture
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.domain.Match
@@ -473,26 +484,78 @@ fun TeamPickerBar(
team: Team,
showPicker: Boolean,
onClick: () -> Unit,
expandable: Boolean = true,
) {
if (!showPicker) return
val shape = RoundedCornerShape(14.dp)
Row(
Modifier
.fillMaxWidth()
.background(MatchColors.Surface, RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 14.dp, vertical = 12.dp),
.clip(shape)
.background(MatchColors.SurfaceElevated, shape)
.border(
BorderStroke(
width = if (expandable) 1.5.dp else 1.dp,
color = if (expandable) MatchColors.PrimaryRed.copy(alpha = 0.55f) else MatchColors.Outline,
),
shape,
)
.then(if (expandable) Modifier.clickable(onClick = onClick) else Modifier)
.padding(horizontal = 14.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Icon(Icons.Default.Groups, null, tint = MatchColors.PrimaryRed)
Box(
Modifier
.size(40.dp)
.background(MatchColors.PrimaryRed.copy(alpha = 0.16f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(
Icons.Default.Groups,
contentDescription = null,
tint = MatchColors.PrimaryRed,
modifier = Modifier.size(22.dp),
)
}
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text("Squadra per la diretta", style = MaterialTheme.typography.bodyMedium)
Text(
if (expandable) {
stringResource(R.string.matches_tap_change_team)
} else {
stringResource(R.string.matches_team_for_live)
},
color = if (expandable) MatchColors.PrimaryRed else MatchColors.TextSecondary,
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
)
Text(team.name, style = MaterialTheme.typography.titleMedium)
team.clubName?.takeIf { it.isNotBlank() }?.let {
Text(it, style = MaterialTheme.typography.bodyMedium)
}
}
Icon(Icons.Default.ChevronRight, null, tint = MatchColors.TextSecondary)
if (expandable) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier
.background(MatchColors.PrimaryRed.copy(alpha = 0.14f), RoundedCornerShape(999.dp))
.padding(horizontal = 10.dp, vertical = 6.dp),
) {
Text(
stringResource(R.string.matches_change),
color = MatchColors.PrimaryRed,
fontSize = 12.sp,
fontWeight = FontWeight.Bold,
)
Icon(
Icons.Default.ExpandMore,
contentDescription = stringResource(R.string.matches_change),
tint = MatchColors.PrimaryRed,
modifier = Modifier.size(18.dp),
)
}
}
}
}
@@ -17,7 +17,9 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Logout
import androidx.compose.material.icons.filled.DeleteOutline
import androidx.compose.material.icons.filled.Language
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -41,14 +43,17 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.isScheduledFuture
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
import com.matchlivetv.match_live_tv.ui.components.MatchScreenScaffold
@@ -82,6 +87,7 @@ fun MatchesScreen(
var resumeMatch by remember { mutableStateOf<Match?>(null) }
var configureMatch by remember { mutableStateOf<Match?>(null) }
var deleteMatch by remember { mutableStateOf<Match?>(null) }
var showLanguagePicker by remember { mutableStateOf(false) }
suspend fun showMessage(message: String) {
snackbarHostState.showSnackbar(message)
@@ -159,18 +165,32 @@ fun MatchesScreen(
titleContentColor = Color.White,
),
actions = {
IconButton(onClick = { showLanguagePicker = true }) {
Icon(
imageVector = Icons.Filled.Language,
contentDescription = stringResource(R.string.language_label),
tint = MatchColors.TextSecondary,
)
}
IconButton(onClick = {
scope.launch {
container.authRepository.logout()
onLogout()
}
}) {
Text("Esci", color = MatchColors.TextSecondary)
Icon(
imageVector = Icons.AutoMirrored.Filled.Logout,
contentDescription = stringResource(R.string.action_logout),
tint = MatchColors.TextSecondary,
)
}
},
)
},
) {
if (showLanguagePicker) {
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
}
Box(Modifier.fillMaxSize()) {
PullToRefreshBox(
isRefreshing = refreshing,
@@ -192,7 +212,10 @@ fun MatchesScreen(
) {
Text(error!!, color = MatchColors.PrimaryRed, textAlign = TextAlign.Center)
Spacer(Modifier.height(16.dp))
MatchPrimaryButton(label = "RIPROVA", onClick = { reload(showInitialSpinner = false) })
MatchPrimaryButton(
label = stringResource(R.string.matches_retry).uppercase(),
onClick = { reload(showInitialSpinner = false) },
)
}
else -> LazyColumn(
contentPadding = PaddingValues(bottom = 24.dp),
@@ -200,23 +223,26 @@ fun MatchesScreen(
item {
Column(Modifier.padding(horizontal = 20.dp, vertical = 8.dp)) {
Text(
"Ciao, ${session?.user?.name.orEmpty()}",
stringResource(
R.string.matches_hello,
session?.user?.name.orEmpty(),
),
style = MaterialTheme.typography.headlineMedium,
)
Spacer(Modifier.height(4.dp))
Text(
"Riprendi una diretta in corso o avvia una partita programmata.",
stringResource(R.string.matches_subtitle),
style = MaterialTheme.typography.bodyMedium,
)
Spacer(Modifier.height(16.dp))
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
MatchSecondaryButton(
label = "PARTITA PROGRAMMATA",
label = stringResource(R.string.matches_schedule).uppercase(),
onClick = { showScheduleSheet = true },
modifier = Modifier.weight(1f),
)
MatchPrimaryButton(
label = "NUOVA PARTITA",
label = stringResource(R.string.matches_new).uppercase(),
onClick = { showNewMatchSheet = true },
modifier = Modifier.weight(1f),
)
@@ -226,6 +252,7 @@ fun MatchesScreen(
TeamPickerBar(
team = team,
showPicker = true,
expandable = teams.size > 1,
onClick = { if (teams.size > 1) showTeamSheet = true },
)
}
@@ -238,9 +265,11 @@ fun MatchesScreen(
item {
Text(
when {
calendarMatches.isEmpty() && activeMatch == null -> "Nessuna partita in calendario"
scheduledMatches.isNotEmpty() -> "Partite programmate"
else -> "Pronte da avviare"
calendarMatches.isEmpty() && activeMatch == null ->
stringResource(R.string.matches_empty_title)
scheduledMatches.isNotEmpty() ->
stringResource(R.string.matches_scheduled_title)
else -> stringResource(R.string.matches_ready_title)
},
style = MaterialTheme.typography.labelLarge,
color = MatchColors.TextSecondary,
@@ -249,16 +278,25 @@ fun MatchesScreen(
}
if (calendarMatches.isEmpty() && activeMatch == null) {
item {
val emptyHint = stringResource(R.string.matches_empty_hint)
val activeTeamLine = activeTeam?.name?.let {
stringResource(R.string.matches_active_team, it)
}
val multiTeamHint = if (teams.size > 1) {
stringResource(R.string.matches_multi_team_hint)
} else {
null
}
Text(
buildString {
append("Programma una partita o avviane una nuova con «Nuova partita».")
activeTeam?.name?.let { teamName ->
append("\n\nSquadra attiva: ")
append(teamName)
append('.')
append(emptyHint)
activeTeamLine?.let {
append("\n\n")
append(it)
}
if (teams.size > 1) {
append("\nHai più squadre: verifica quella selezionata sopra.")
multiTeamHint?.let {
append("\n")
append(it)
}
},
style = MaterialTheme.typography.bodyMedium,
@@ -269,7 +307,7 @@ fun MatchesScreen(
} else if (calendarMatches.isEmpty()) {
item {
Text(
"Nessuna altra partita in calendario.",
stringResource(R.string.matches_no_other),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 12.dp),
@@ -437,19 +475,18 @@ private fun NoTeamContent(onRetry: () -> Unit) {
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Nessuna squadra assegnata",
stringResource(R.string.matches_no_team_title),
style = MaterialTheme.typography.headlineMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
"Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. " +
"Chiedi al tuo club di aggiungerti come staff trasmissione.",
stringResource(R.string.matches_no_team_body),
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
MatchPrimaryButton(label = "RIPROVA", onClick = onRetry)
MatchPrimaryButton(label = stringResource(R.string.matches_retry).uppercase(), onClick = onRetry)
}
}
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="app_slogan">Jedes Spiel, jedes Event, für eure Fans.</string>
<string name="language_label">Sprache</string>
<string name="language_system">System</string>
<string name="language_italian">Italiano</string>
<string name="language_english">English</string>
<string name="language_french">Français</string>
<string name="language_german">Deutsch</string>
<string name="language_spanish">Español</string>
<string name="action_logout">Abmelden</string>
<string name="action_login">Anmelden</string>
<string name="action_cancel">Abbrechen</string>
<string name="action_save">Speichern</string>
<string name="login_email">E-Mail</string>
<string name="login_password">Passwort</string>
<string name="login_submit">Anmelden</string>
<string name="login_error_credentials">Falsche E-Mail oder Passwort</string>
<string name="login_error_unreachable">Server nicht erreichbar. Verbindung prüfen.</string>
<string name="login_error_generic">Anmeldung fehlgeschlagen</string>
<string name="matches_title">Spiele</string>
<string name="matches_hello">Hallo, %1$s</string>
<string name="matches_subtitle">Nimm einen laufenden Livestream wieder auf oder starte ein geplantes Spiel.</string>
<string name="matches_schedule">Geplantes Spiel</string>
<string name="matches_new">Neues Spiel</string>
<string name="matches_empty_title">Keine Spiele im Kalender</string>
<string name="matches_scheduled_title">Geplante Spiele</string>
<string name="matches_ready_title">Bereit zum Start</string>
<string name="matches_empty_hint">Plane ein Spiel oder starte ein neues mit «Neues Spiel».</string>
<string name="matches_active_team">Aktives Team: %1$s.</string>
<string name="matches_multi_team_hint">Du hast mehrere Teams: prüfe die oben ausgewählte.</string>
<string name="matches_no_other">Keine weiteren Spiele im Kalender.</string>
<string name="matches_retry">Erneut versuchen</string>
<string name="matches_load_error">Ladefehler</string>
<string name="matches_no_team_title">Kein Team zugewiesen</string>
<string name="matches_no_team_body">Zum Streamen musst du Übertragungs-Staff für mindestens ein Team sein. Bitte deinen Verein, dich als Streaming-Staff hinzuzufügen.</string>
<string name="matches_tap_change_team">Tippen zum Teamwechsel</string>
<string name="matches_team_for_live">Team für den Livestream</string>
<string name="matches_change">Wechseln</string>
<string name="streaming_notification_channel">Livestream</string>
<string name="streaming_notification_channel_desc">Benachrichtigung während des Livestreams</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Stream wird vorbereitet…</string>
<string name="streaming_notification_connecting">RTMP-Verbindung…</string>
<string name="streaming_notification_streaming">Live</string>
<string name="streaming_notification_reconnecting">Erneute Verbindung…</string>
<string name="streaming_notification_error">Streaming-Fehler</string>
<string name="streaming_notification_stop">Beenden</string>
</resources>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="app_slogan">Every match, every event, for your fans.</string>
<string name="language_label">Language</string>
<string name="language_system">System</string>
<string name="language_italian">Italiano</string>
<string name="language_english">English</string>
<string name="language_french">Français</string>
<string name="language_german">Deutsch</string>
<string name="language_spanish">Español</string>
<string name="action_logout">Log out</string>
<string name="action_login">Log in</string>
<string name="action_cancel">Cancel</string>
<string name="action_save">Save</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_submit">Log in</string>
<string name="login_error_credentials">Incorrect email or password</string>
<string name="login_error_unreachable">Server unreachable. Check your connection.</string>
<string name="login_error_generic">Login failed</string>
<string name="matches_title">Matches</string>
<string name="matches_hello">Hi, %1$s</string>
<string name="matches_subtitle">Resume a live stream or start a scheduled match.</string>
<string name="matches_schedule">Scheduled match</string>
<string name="matches_new">New match</string>
<string name="matches_empty_title">No matches on the calendar</string>
<string name="matches_scheduled_title">Scheduled matches</string>
<string name="matches_ready_title">Ready to start</string>
<string name="matches_empty_hint">Schedule a match or start a new one with «New match».</string>
<string name="matches_active_team">Active team: %1$s.</string>
<string name="matches_multi_team_hint">You have multiple teams: check the one selected above.</string>
<string name="matches_no_other">No other matches on the calendar.</string>
<string name="matches_retry">Retry</string>
<string name="matches_load_error">Loading error</string>
<string name="matches_no_team_title">No team assigned</string>
<string name="matches_no_team_body">To stream you must be transmission staff for at least one team. Ask your club to add you as streaming staff.</string>
<string name="matches_tap_change_team">Tap to change team</string>
<string name="matches_team_for_live">Team for the live stream</string>
<string name="matches_change">Change</string>
<string name="streaming_notification_channel">Live stream</string>
<string name="streaming_notification_channel_desc">Notification while live streaming</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Preparing stream…</string>
<string name="streaming_notification_connecting">Connecting RTMP…</string>
<string name="streaming_notification_streaming">Live</string>
<string name="streaming_notification_reconnecting">Reconnecting…</string>
<string name="streaming_notification_error">Streaming error</string>
<string name="streaming_notification_stop">Stop</string>
</resources>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="app_slogan">Cada partido, cada evento, para tus aficionados.</string>
<string name="language_label">Idioma</string>
<string name="language_system">Sistema</string>
<string name="language_italian">Italiano</string>
<string name="language_english">English</string>
<string name="language_french">Français</string>
<string name="language_german">Deutsch</string>
<string name="language_spanish">Español</string>
<string name="action_logout">Salir</string>
<string name="action_login">Acceder</string>
<string name="action_cancel">Cancelar</string>
<string name="action_save">Guardar</string>
<string name="login_email">Email</string>
<string name="login_password">Contraseña</string>
<string name="login_submit">Acceder</string>
<string name="login_error_credentials">Email o contraseña incorrectos</string>
<string name="login_error_unreachable">Servidor no disponible. Comprueba la conexión.</string>
<string name="login_error_generic">Error de acceso</string>
<string name="matches_title">Partidos</string>
<string name="matches_hello">Hola, %1$s</string>
<string name="matches_subtitle">Reanuda un directo en curso o inicia un partido programado.</string>
<string name="matches_schedule">Partido programado</string>
<string name="matches_new">Nuevo partido</string>
<string name="matches_empty_title">Ningún partido en el calendario</string>
<string name="matches_scheduled_title">Partidos programados</string>
<string name="matches_ready_title">Listos para empezar</string>
<string name="matches_empty_hint">Programa un partido o inicia uno nuevo con «Nuevo partido».</string>
<string name="matches_active_team">Equipo activo: %1$s.</string>
<string name="matches_multi_team_hint">Tienes varios equipos: comprueba el seleccionado arriba.</string>
<string name="matches_no_other">Ningún otro partido en el calendario.</string>
<string name="matches_retry">Reintentar</string>
<string name="matches_load_error">Error de carga</string>
<string name="matches_no_team_title">Ningún equipo asignado</string>
<string name="matches_no_team_body">Para emitir debes ser responsable de transmisión de al menos un equipo. Pide a tu club que te añada como staff de streaming.</string>
<string name="matches_tap_change_team">Toca para cambiar de equipo</string>
<string name="matches_team_for_live">Equipo para el directo</string>
<string name="matches_change">Cambiar</string>
<string name="streaming_notification_channel">Directo en curso</string>
<string name="streaming_notification_channel_desc">Notificación durante el streaming en vivo</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Preparando el directo…</string>
<string name="streaming_notification_connecting">Conectando RTMP…</string>
<string name="streaming_notification_streaming">En directo</string>
<string name="streaming_notification_reconnecting">Reconectando…</string>
<string name="streaming_notification_error">Error de streaming</string>
<string name="streaming_notification_stop">Terminar</string>
</resources>
@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Match Live TV</string>
<string name="app_slogan">Chaque match, chaque événement, pour vos fans.</string>
<string name="language_label">Langue</string>
<string name="language_system">Système</string>
<string name="language_italian">Italiano</string>
<string name="language_english">English</string>
<string name="language_french">Français</string>
<string name="language_german">Deutsch</string>
<string name="language_spanish">Español</string>
<string name="action_logout">Déconnexion</string>
<string name="action_login">Connexion</string>
<string name="action_cancel">Annuler</string>
<string name="action_save">Enregistrer</string>
<string name="login_email">E-mail</string>
<string name="login_password">Mot de passe</string>
<string name="login_submit">Connexion</string>
<string name="login_error_credentials">E-mail ou mot de passe incorrect</string>
<string name="login_error_unreachable">Serveur inaccessible. Vérifiez la connexion.</string>
<string name="login_error_generic">Échec de la connexion</string>
<string name="matches_title">Matchs</string>
<string name="matches_hello">Bonjour, %1$s</string>
<string name="matches_subtitle">Reprenez un direct en cours ou démarrez un match programmé.</string>
<string name="matches_schedule">Match programmé</string>
<string name="matches_new">Nouveau match</string>
<string name="matches_empty_title">Aucun match au calendrier</string>
<string name="matches_scheduled_title">Matchs programmés</string>
<string name="matches_ready_title">Prêts à démarrer</string>
<string name="matches_empty_hint">Programmez un match ou démarrez-en un avec « Nouveau match ».</string>
<string name="matches_active_team">Équipe active : %1$s.</string>
<string name="matches_multi_team_hint">Vous avez plusieurs équipes : vérifiez celle sélectionnée ci-dessus.</string>
<string name="matches_no_other">Aucun autre match au calendrier.</string>
<string name="matches_retry">Réessayer</string>
<string name="matches_load_error">Erreur de chargement</string>
<string name="matches_no_team_title">Aucune équipe assignée</string>
<string name="matches_no_team_body">Pour diffuser, vous devez être responsable de la transmission d\'au moins une équipe. Demandez à votre club de vous ajouter comme staff diffusion.</string>
<string name="matches_tap_change_team">Appuyez pour changer d\'équipe</string>
<string name="matches_team_for_live">Équipe pour le direct</string>
<string name="matches_change">Changer</string>
<string name="streaming_notification_channel">Direct en cours</string>
<string name="streaming_notification_channel_desc">Notification pendant le streaming live</string>
<string name="streaming_notification_title">Match Live TV</string>
<string name="streaming_notification_active">Préparation du direct…</string>
<string name="streaming_notification_connecting">Connexion RTMP…</string>
<string name="streaming_notification_streaming">En direct</string>
<string name="streaming_notification_reconnecting">Reconnexion…</string>
<string name="streaming_notification_error">Erreur de streaming</string>
<string name="streaming_notification_stop">Arrêter</string>
</resources>
@@ -2,6 +2,42 @@
<resources>
<string name="app_name">Match Live TV</string>
<string name="app_slogan">Ogni partita, ogni evento, per i tuoi tifosi.</string>
<string name="language_label">Lingua</string>
<string name="language_system">Sistema</string>
<string name="language_italian">Italiano</string>
<string name="language_english">English</string>
<string name="language_french">Français</string>
<string name="language_german">Deutsch</string>
<string name="language_spanish">Español</string>
<string name="action_logout">Esci</string>
<string name="action_login">Accedi</string>
<string name="action_cancel">Annulla</string>
<string name="action_save">Salva</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_submit">Accedi</string>
<string name="login_error_credentials">Email o password non corretti</string>
<string name="login_error_unreachable">Server non raggiungibile. Verifica la connessione.</string>
<string name="login_error_generic">Login fallito</string>
<string name="matches_title">Partite</string>
<string name="matches_hello">Ciao, %1$s</string>
<string name="matches_subtitle">Riprendi una diretta in corso o avvia una partita programmata.</string>
<string name="matches_schedule">Partita programmata</string>
<string name="matches_new">Nuova partita</string>
<string name="matches_empty_title">Nessuna partita in calendario</string>
<string name="matches_scheduled_title">Partite programmate</string>
<string name="matches_ready_title">Pronte da avviare</string>
<string name="matches_empty_hint">Programma una partita o avviane una nuova con «Nuova partita».</string>
<string name="matches_active_team">Squadra attiva: %1$s.</string>
<string name="matches_multi_team_hint">Hai più squadre: verifica quella selezionata sopra.</string>
<string name="matches_no_other">Nessuna altra partita in calendario.</string>
<string name="matches_retry">Riprova</string>
<string name="matches_load_error">Errore caricamento</string>
<string name="matches_no_team_title">Nessuna squadra assegnata</string>
<string name="matches_no_team_body">Per trasmettere devi essere responsabile della trasmissione di almeno una squadra. Chiedi al tuo club di aggiungerti come staff trasmissione.</string>
<string name="matches_tap_change_team">Tocca per cambiare squadra</string>
<string name="matches_team_for_live">Squadra per la diretta</string>
<string name="matches_change">Cambia</string>
<string name="streaming_notification_channel">Diretta in corso</string>
<string name="streaming_notification_channel_desc">Notifica durante lo streaming live</string>
<string name="streaming_notification_title">Match Live TV</string>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="it" />
<locale android:name="en" />
<locale android:name="fr" />
<locale android:name="de" />
<locale android:name="es" />
</locale-config>
@@ -0,0 +1,33 @@
package com.matchlivetv.match_live_tv.core
import org.junit.Assert.assertEquals
import org.junit.Test
class RtmpIngestUrlTest {
@Test
fun rewritesLoopbackToApiHost() {
val resolved = RtmpIngestUrl.resolve(
"rtmp://127.0.0.1:1935/live/match_abc",
apiBaseUrl = "http://10.0.2.2:3000",
)
assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved)
}
@Test
fun rewritesDockerInternalHost() {
val resolved = RtmpIngestUrl.resolve(
"rtmp://mediamtx:1935/live/match_abc",
apiBaseUrl = "http://10.0.2.2:3000",
)
assertEquals("rtmp://10.0.2.2:1935/live/match_abc", resolved)
}
@Test
fun keepsProductionHost() {
val input = "rtmp://stream.matchlivetv.it:1935/live/match_abc"
assertEquals(
input,
RtmpIngestUrl.resolve(input, apiBaseUrl = "https://www.matchlivetv.it"),
)
}
}
+1 -1
View File
@@ -1,5 +1,5 @@
plugins {
id("com.android.application") version "8.7.3" apply false
id("com.android.application") version "8.9.1" apply false
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
id("org.jetbrains.kotlin.plugin.compose") version "2.1.0" apply false
}
@@ -65,6 +65,7 @@
C1D2EA29B25C4B06B03B6FFE /* BroadcastVideoOrientation.swift in Sources */ = {isa = PBXBuildFile; fileRef = BCA50054BF694B9D82EA97ED /* BroadcastVideoOrientation.swift */; };
C3B1B44C77084C05B325F935 /* StepNetworkTestScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C26A0114F73483A85C70772 /* StepNetworkTestScreen.swift */; };
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 354D09291641417F99E27D5A /* ApiInstant.swift */; };
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */; };
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */ = {isa = PBXBuildFile; fileRef = 258DECC661314224B6A98F47 /* DeviceTelemetry.swift */; };
D08665EFFF27465294B620B5 /* ScoreControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8F7A645B14A44C69304E136 /* ScoreControllerTests.swift */; };
DBBB006ED6C946A2950D805C /* StepMatchScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8992D152CF7F478BA6469E36 /* StepMatchScreen.swift */; };
@@ -112,6 +113,7 @@
29358C1BDC864C408AB4FAD0 /* WizardShellScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WizardShellScreen.swift; path = MatchLiveTv/UI/Wizard/WizardShellScreen.swift; sourceTree = "<group>"; };
2DF2832197EE409AA0B0334C /* BroadcastControlsOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BroadcastControlsOverlay.swift; path = MatchLiveTv/UI/Broadcast/BroadcastControlsOverlay.swift; sourceTree = "<group>"; };
354D09291641417F99E27D5A /* ApiInstant.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ApiInstant.swift; path = MatchLiveTv/Core/ApiInstant.swift; sourceTree = "<group>"; };
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppLanguage.swift; path = MatchLiveTv/Core/AppLanguage.swift; sourceTree = "<group>"; };
3691F2136AAD4545B655F544 /* SessionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SessionRepository.swift; path = MatchLiveTv/Data/Repository/SessionRepository.swift; sourceTree = "<group>"; };
395345091E364AFF9FFB34B9 /* AppConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppConfig.swift; path = MatchLiveTv/Core/AppConfig.swift; sourceTree = "<group>"; };
49FD73A6D4254EAD9484E159 /* OverlayRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = OverlayRenderer.swift; path = MatchLiveTv/Streaming/Overlay/OverlayRenderer.swift; sourceTree = "<group>"; };
@@ -218,6 +220,7 @@
children = (
13EB02413D134D6289DB90DA /* MatchLiveTvApp.swift */,
354D09291641417F99E27D5A /* ApiInstant.swift */,
B2C3D4E5F6478901A2B3C4D5 /* AppLanguage.swift */,
395345091E364AFF9FFB34B9 /* AppConfig.swift */,
68D5ED55DB884EA2AC986F61 /* ColorHex.swift */,
258DECC661314224B6A98F47 /* DeviceTelemetry.swift */,
@@ -393,6 +396,7 @@
files = (
42EF33716F6843B69945E6AB /* MatchLiveTvApp.swift in Sources */,
C9B90AAE70C142DEB8996100 /* ApiInstant.swift in Sources */,
A1B2C3D4E5F64789A0B1C2D3 /* AppLanguage.swift in Sources */,
066B7E46EB6245BA94C12BA6 /* AppConfig.swift in Sources */,
557CA481D63840D3AA6EFD5B /* ColorHex.swift in Sources */,
CF8E45A2BFE34E4BBE259654 /* DeviceTelemetry.swift in Sources */,
@@ -0,0 +1,181 @@
import Foundation
import SwiftUI
/// Preferenza lingua app (override del sistema). Persistita in UserDefaults.
enum AppLanguage: String, CaseIterable, Identifiable {
case system
case it
case en
case fr
case de
case es
var id: String { rawValue }
var nativeLabel: String {
switch self {
case .system: return L10n.t("language.system")
case .it: return "Italiano"
case .en: return "English"
case .fr: return "Français"
case .de: return "Deutsch"
case .es: return "Español"
}
}
static var current: AppLanguage {
get {
let raw = UserDefaults.standard.string(forKey: storageKey) ?? system.rawValue
return AppLanguage(rawValue: raw) ?? .system
}
set {
if newValue == .system {
UserDefaults.standard.removeObject(forKey: storageKey)
} else {
UserDefaults.standard.set(newValue.rawValue, forKey: storageKey)
}
// Forza refresh UI tramite notification
NotificationCenter.default.post(name: .appLanguageDidChange, object: nil)
}
}
/// Locale effettiva per le stringhe (risolve "system").
static var resolvedCode: String {
switch current {
case .system:
let code = Locale.current.language.languageCode?.identifier ?? "it"
let supported = ["it", "en", "fr", "de", "es"]
return supported.contains(code) ? code : "en"
default:
return current.rawValue
}
}
private static let storageKey = "mltv.appLanguage"
}
extension Notification.Name {
static let appLanguageDidChange = Notification.Name("mltv.appLanguageDidChange")
}
enum L10n {
static func t(_ key: String) -> String {
let code = AppLanguage.resolvedCode
if let value = table[code]?[key] { return value }
if let value = table["it"]?[key] { return value }
return key
}
private static let table: [String: [String: String]] = [
"it": [
"language.label": "Lingua",
"language.system": "Sistema",
"action.logout": "Esci",
"action.login": "Accedi",
"action.cancel": "Annulla",
"login.email": "Email",
"login.password": "Password",
"login.submit": "Accedi",
"login.error.credentials": "Email o password non corretti",
"login.error.unreachable": "Server non raggiungibile. Verifica la connessione.",
"login.error.generic": "Login fallito",
"matches.title": "Partite",
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
],
"en": [
"language.label": "Language",
"language.system": "System",
"action.logout": "Log out",
"action.login": "Log in",
"action.cancel": "Cancel",
"login.email": "Email",
"login.password": "Password",
"login.submit": "Log in",
"login.error.credentials": "Incorrect email or password",
"login.error.unreachable": "Server unreachable. Check your connection.",
"login.error.generic": "Login failed",
"matches.title": "Matches",
"app.slogan": "Every match, every event, for your fans.",
],
"fr": [
"language.label": "Langue",
"language.system": "Système",
"action.logout": "Déconnexion",
"action.login": "Connexion",
"action.cancel": "Annuler",
"login.email": "E-mail",
"login.password": "Mot de passe",
"login.submit": "Connexion",
"login.error.credentials": "E-mail ou mot de passe incorrect",
"login.error.unreachable": "Serveur inaccessible. Vérifiez la connexion.",
"login.error.generic": "Échec de la connexion",
"matches.title": "Matchs",
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
],
"de": [
"language.label": "Sprache",
"language.system": "System",
"action.logout": "Abmelden",
"action.login": "Anmelden",
"action.cancel": "Abbrechen",
"login.email": "E-Mail",
"login.password": "Passwort",
"login.submit": "Anmelden",
"login.error.credentials": "Falsche E-Mail oder Passwort",
"login.error.unreachable": "Server nicht erreichbar. Verbindung prüfen.",
"login.error.generic": "Anmeldung fehlgeschlagen",
"matches.title": "Spiele",
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
],
"es": [
"language.label": "Idioma",
"language.system": "Sistema",
"action.logout": "Salir",
"action.login": "Acceder",
"action.cancel": "Cancelar",
"login.email": "Email",
"login.password": "Contraseña",
"login.submit": "Acceder",
"login.error.credentials": "Email o contraseña incorrectos",
"login.error.unreachable": "Servidor no disponible. Comprueba la conexión.",
"login.error.generic": "Error de acceso",
"matches.title": "Partidos",
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
],
]
}
struct LanguagePickerView: View {
@Environment(\.dismiss) private var dismiss
@State private var selected = AppLanguage.current
var body: some View {
NavigationStack {
List {
ForEach(AppLanguage.allCases) { lang in
Button {
selected = lang
AppLanguage.current = lang
dismiss()
} label: {
HStack {
Text(lang.nativeLabel)
.foregroundStyle(MatchColors.textSecondary)
Spacer()
if selected == lang {
Image(systemName: "checkmark")
.foregroundStyle(MatchColors.primaryRed)
}
}
}
}
}
.navigationTitle(L10n.t("language.label"))
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L10n.t("action.cancel")) { dismiss() }
}
}
}
}
}
@@ -9,6 +9,8 @@ struct LoginScreen: View {
@State private var error: String?
@State private var loading = false
@State private var passwordVisible = false
@State private var showLanguagePicker = false
@State private var languageTick = 0
var body: some View {
MatchScreenScaffold {
@@ -16,17 +18,17 @@ struct LoginScreen: View {
VStack(spacing: 0) {
MatchLiveWordmark(showSlogan: true)
.padding(.top, 32)
Text("ACCEDI")
Button(L10n.t("language.label")) {
showLanguagePicker = true
}
.foregroundStyle(MatchColors.textSecondary)
.padding(.top, 16)
Text(L10n.t("login.submit").uppercased())
.font(MatchTypography.headlineMedium)
.padding(.top, 48)
Text("Gestisci le dirette della tua squadra")
.font(MatchTypography.bodyMedium)
.foregroundStyle(MatchColors.textSecondary)
.multilineTextAlignment(.center)
.padding(.top, 8)
.padding(.top, 32)
VStack(spacing: 16) {
MatchTextField(title: "Email", text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
MatchSecureField(title: "Password", text: $password, visible: $passwordVisible)
MatchTextField(title: L10n.t("login.email"), text: $email, placeholder: "coach@squadra.it", keyboard: .emailAddress)
MatchSecureField(title: L10n.t("login.password"), text: $password, visible: $passwordVisible)
}
.padding(.top, 32)
if let error {
@@ -36,7 +38,7 @@ struct LoginScreen: View {
.padding(.top, 12)
}
MatchPrimaryButton(
label: "ACCEDI",
label: L10n.t("login.submit").uppercased(),
action: submitLogin,
enabled: !email.isEmpty && !password.isEmpty,
loading: loading
@@ -47,6 +49,13 @@ struct LoginScreen: View {
.padding(.bottom, 32)
}
}
.sheet(isPresented: $showLanguagePicker) {
LanguagePickerView()
}
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.id(languageTick)
}
private func submitLogin() {
@@ -59,12 +68,12 @@ struct LoginScreen: View {
onLoggedIn()
} catch {
if let apiError = error as? APIError, case .unauthorized = apiError {
self.error = "Email o password non corretti"
self.error = L10n.t("login.error.credentials")
} else if let message = UserFacingError.message(for: error),
message.localizedCaseInsensitiveContains("timeout") {
self.error = "Server non raggiungibile. Verifica la connessione."
self.error = L10n.t("login.error.unreachable")
} else {
self.error = UserFacingError.message(for: error) ?? "Accesso non riuscito"
self.error = UserFacingError.message(for: error) ?? L10n.t("login.error.generic")
}
}
loading = false
@@ -20,6 +20,8 @@ struct MatchesScreen: View {
@State private var resumeMatch: Match?
@State private var deleteMatch: Match?
@State private var snackbar: String?
@State private var showLanguagePicker = false
@State private var languageTick = 0
var body: some View {
MatchScreenScaffold(
@@ -27,7 +29,14 @@ struct MatchesScreen: View {
HStack {
MatchLiveWordmark(compact: true)
Spacer()
Button("Esci") {
Button {
showLanguagePicker = true
} label: {
Image(systemName: "globe")
.foregroundStyle(MatchColors.textSecondary)
}
.accessibilityLabel(L10n.t("language.label"))
Button(L10n.t("action.logout")) {
Task {
await container.authRepository.logout()
onLogout()
@@ -177,6 +186,13 @@ struct MatchesScreen: View {
showTeamPicker = false
}
}
.sheet(isPresented: $showLanguagePicker) {
LanguagePickerView()
}
.onReceive(NotificationCenter.default.publisher(for: .appLanguageDidChange)) { _ in
languageTick += 1
}
.id(languageTick)
.overlay {
if actionLoading {
Color.black.opacity(0.35)