Sito marketing, SEO, legal, live programmata e branding Match Live Tv.
Logo e favicon nel header, partite programmate su web e mobile, reset password, pagine FAQ/sitemap, privacy/termini GDPR e icona Android «Match Live Tv». Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -7,7 +7,7 @@ module Api
|
||||
def index
|
||||
matches = @team.matches
|
||||
.includes(:team, :stream_sessions)
|
||||
.order(scheduled_at: :desc)
|
||||
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
|
||||
render json: matches.map { |m| match_json(m) }
|
||||
end
|
||||
|
||||
|
||||
@@ -12,6 +12,15 @@ module Public
|
||||
.search_by_team_or_opponent(@query)
|
||||
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
|
||||
|
||||
busy_match_ids = StreamSession.where.not(status: %w[ended error]).select(:match_id)
|
||||
@upcoming_matches = Match
|
||||
.scheduled_upcoming
|
||||
.includes(:team)
|
||||
.search_teams_or_opponents(@query)
|
||||
.where.not(id: busy_match_ids)
|
||||
.order(scheduled_at: :asc)
|
||||
.limit(30)
|
||||
|
||||
@online_paths = Mediamtx::Client.new.online_path_names
|
||||
end
|
||||
|
||||
|
||||
@@ -15,5 +15,11 @@ module Public
|
||||
|
||||
def terms
|
||||
end
|
||||
|
||||
def faq
|
||||
end
|
||||
|
||||
def pallavolo
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
52
backend/app/controllers/public/password_resets_controller.rb
Normal file
@@ -0,0 +1,52 @@
|
||||
module Public
|
||||
class PasswordResetsController < WebBaseController
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
||||
if user
|
||||
token = user.generate_password_reset!
|
||||
UserMailer.password_reset(user, token).deliver_now
|
||||
end
|
||||
|
||||
redirect_to public_login_path,
|
||||
notice: "Se l'email è registrata, riceverai a breve un link per reimpostare la password."
|
||||
end
|
||||
|
||||
def edit
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
alert: "Link non valido o scaduto. Richiedi un nuovo reset password."
|
||||
return
|
||||
end
|
||||
@token = params[:token]
|
||||
end
|
||||
|
||||
def update
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
alert: "Link non valido o scaduto. Richiedi un nuovo reset password."
|
||||
return
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = "La password deve avere almeno 8 caratteri"
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = "Le password non coincidono"
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@user.update!(password: params[:password])
|
||||
@user.clear_password_reset!
|
||||
redirect_to public_login_path, notice: "Password aggiornata. Ora puoi accedere."
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,12 @@ module Public
|
||||
end
|
||||
|
||||
def create
|
||||
unless params[:accept_terms] == "1"
|
||||
@user = User.new(user_params.merge(role: "coach"))
|
||||
flash.now[:alert] = "Devi accettare l’informativa privacy e i termini di servizio per registrarti."
|
||||
return render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@user = User.new(user_params.merge(role: "coach"))
|
||||
if @user.save
|
||||
session[:user_id] = @user.id
|
||||
|
||||
@@ -2,6 +2,8 @@ module Public
|
||||
class SiteBaseController < ActionController::Base
|
||||
layout "marketing"
|
||||
|
||||
include ::SeoHelper
|
||||
include ::LegalHelper
|
||||
helper_method :current_user, :logged_in?
|
||||
|
||||
private
|
||||
|
||||
21
backend/app/controllers/public/sitemap_controller.rb
Normal file
@@ -0,0 +1,21 @@
|
||||
module Public
|
||||
class SitemapController < ActionController::Base
|
||||
def show
|
||||
base = MatchLiveTv.app_public_url.chomp("/")
|
||||
@entries = [
|
||||
{ loc: "#{base}/", changefreq: "weekly", priority: "1.0" },
|
||||
{ loc: "#{base}/funzionalita", changefreq: "monthly", priority: "0.9" },
|
||||
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
|
||||
{ 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}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||
]
|
||||
|
||||
respond_to do |format|
|
||||
format.xml { render layout: false }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
5
backend/app/helpers/legal_helper.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
module LegalHelper
|
||||
def legal_last_updated
|
||||
"26 maggio 2026"
|
||||
end
|
||||
end
|
||||
@@ -15,6 +15,24 @@ module Public
|
||||
partials.map { |p| format_set_partial_entry(p) }.join(" · ")
|
||||
end
|
||||
|
||||
def live_scheduled_label(datetime)
|
||||
return nil unless datetime
|
||||
|
||||
datetime.in_time_zone.strftime("%d/%m/%Y alle %H:%M")
|
||||
end
|
||||
|
||||
def live_scheduled_relative(datetime)
|
||||
return nil unless datetime
|
||||
|
||||
if datetime.to_date == Time.zone.today
|
||||
"Oggi alle #{datetime.strftime('%H:%M')}"
|
||||
elsif datetime.to_date == Time.zone.tomorrow
|
||||
"Domani alle #{datetime.strftime('%H:%M')}"
|
||||
else
|
||||
live_scheduled_label(datetime)
|
||||
end
|
||||
end
|
||||
|
||||
def live_score_points_label(match, score_state)
|
||||
return "—" unless score_state
|
||||
|
||||
|
||||
33
backend/app/helpers/seo_helper.rb
Normal file
@@ -0,0 +1,33 @@
|
||||
module SeoHelper
|
||||
DEFAULT_OG_IMAGE_PATH = "/og-share.png"
|
||||
|
||||
def seo_site_url
|
||||
MatchLiveTv.app_public_url.chomp("/")
|
||||
end
|
||||
|
||||
def seo_absolute_url(path)
|
||||
path = "/#{path}" unless path.start_with?("/")
|
||||
"#{seo_site_url}#{path}"
|
||||
end
|
||||
|
||||
def seo_canonical_url
|
||||
if content_for?(:canonical_url)
|
||||
content_for(:canonical_url)
|
||||
else
|
||||
request.original_url.split("?").first
|
||||
end
|
||||
end
|
||||
|
||||
def seo_og_image_url
|
||||
if content_for?(:og_image)
|
||||
img = content_for(:og_image)
|
||||
img.start_with?("http") ? img : seo_absolute_url(img)
|
||||
else
|
||||
seo_absolute_url(DEFAULT_OG_IMAGE_PATH)
|
||||
end
|
||||
end
|
||||
|
||||
def seo_robots_content
|
||||
content_for?(:robots) ? content_for(:robots) : "index, follow"
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,4 @@
|
||||
class ApplicationMailer < ActionMailer::Base
|
||||
default from: "from@example.com"
|
||||
default from: -> { MatchLiveTv.mail_from }
|
||||
layout "mailer"
|
||||
end
|
||||
|
||||
11
backend/app/mailers/user_mailer.rb
Normal file
@@ -0,0 +1,11 @@
|
||||
class UserMailer < ApplicationMailer
|
||||
default from: -> { MatchLiveTv.mail_from }
|
||||
|
||||
def password_reset(user, token)
|
||||
@user = user
|
||||
@reset_url = public_password_reset_url(token: token)
|
||||
@expiry_hours = MatchLiveTv.password_reset_expiry_hours
|
||||
|
||||
mail to: user.email, subject: "Reimposta la password — Match Live TV"
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,27 @@ class Match < ApplicationRecord
|
||||
belongs_to :team
|
||||
has_many :stream_sessions, dependent: :destroy
|
||||
|
||||
ACTIVE_SESSION_STATUSES = %w[live connecting reconnecting paused].freeze
|
||||
|
||||
validates :opponent_name, presence: true
|
||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
||||
|
||||
scope :scheduled_upcoming, -> {
|
||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.current)
|
||||
}
|
||||
|
||||
scope :search_teams_or_opponents, lambda { |query|
|
||||
q = query.to_s.strip
|
||||
return all if q.blank?
|
||||
|
||||
term = "%#{sanitize_sql_like(q)}%"
|
||||
joins(:team).where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term",
|
||||
term: term
|
||||
)
|
||||
}
|
||||
|
||||
def broadcast_active?
|
||||
stream_sessions.where(status: ACTIVE_SESSION_STATUSES).exists?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,4 +14,32 @@ class User < ApplicationRecord
|
||||
def admin?
|
||||
role == "admin"
|
||||
end
|
||||
|
||||
def generate_password_reset!
|
||||
raw = SecureRandom.urlsafe_base64(32)
|
||||
update!(
|
||||
password_reset_digest: User.digest_token(raw),
|
||||
password_reset_sent_at: Time.current
|
||||
)
|
||||
raw
|
||||
end
|
||||
|
||||
def password_reset_expired?
|
||||
password_reset_sent_at.blank? ||
|
||||
password_reset_sent_at < MatchLiveTv.password_reset_expiry_hours.hours.ago
|
||||
end
|
||||
|
||||
def clear_password_reset!
|
||||
update!(password_reset_digest: nil, password_reset_sent_at: nil)
|
||||
end
|
||||
|
||||
def self.find_by_password_reset_token(token)
|
||||
return if token.blank?
|
||||
|
||||
find_by(password_reset_digest: User.digest_token(token))
|
||||
end
|
||||
|
||||
def self.digest_token(token)
|
||||
Digest::SHA256.hexdigest(token.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<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=15">
|
||||
<link rel="stylesheet" href="/marketing.css?v=20">
|
||||
</head>
|
||||
<body>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<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=15">
|
||||
<link rel="stylesheet" href="/live.css">
|
||||
<link rel="stylesheet" href="/marketing.css?v=20">
|
||||
<link rel="stylesheet" href="/live.css?v=2">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "Invito squadra" %>
|
||||
<% content_for :title, "Invito squadra — Match Live TV" %>
|
||||
<% content_for :meta_description, "Accetta l'invito per entrare nello staff della squadra su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<% content_for :title do %>Dirette in corso — Match Live TV<% end %>
|
||||
<% content_for :title, "Dirette live partite giovanili in corso — Match Live TV" %>
|
||||
<% content_for :meta_description, "Guarda le dirette live delle partite giovanili ora in corso e le prossime gare programmate. Cerca la tua squadra e apri il link senza installare app." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_live_index_path) %>
|
||||
|
||||
<div class="wrap">
|
||||
<h1>Dirette in corso</h1>
|
||||
<h1>Dirette e partite in programma</h1>
|
||||
<p class="results-hint">
|
||||
Cerca per nome squadra o avversario, poi apri la diretta per guardare lo stream.
|
||||
Cerca la tua squadra: trovi le dirette attive e le partite già programmate dall’app.
|
||||
</p>
|
||||
|
||||
<%= form_with url: public_live_index_path, method: :get, local: true, class: "search-form" do %>
|
||||
@@ -23,11 +25,13 @@
|
||||
|
||||
<% if @query.present? %>
|
||||
<p class="results-hint">
|
||||
Risultati per «<%= h @query %>»: <%= @sessions.size %> diretta<%= @sessions.size == 1 ? "" : "e" %>
|
||||
Risultati per «<%= h @query %>»:
|
||||
<%= @sessions.size %> in diretta · <%= @upcoming_matches.size %> in programma
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if @sessions.any? %>
|
||||
<h2 class="section-heading">In diretta adesso</h2>
|
||||
<div class="live-grid">
|
||||
<% @sessions.each do |session| %>
|
||||
<% match = session.match %>
|
||||
@@ -62,15 +66,70 @@
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="empty-state">
|
||||
<% if @query.present? %>
|
||||
<p>Nessuna diretta attiva per «<%= h @query %>».</p>
|
||||
<p>Prova un altro nome squadra o <%= link_to "mostra tutte le dirette", public_live_index_path %>.</p>
|
||||
<% else %>
|
||||
<p>Al momento non ci sono dirette attive.</p>
|
||||
<p>Quando una squadra avvia lo streaming dall’app, la partita comparirà qui.</p>
|
||||
<% end %>
|
||||
|
||||
<% if @upcoming_matches.any? %>
|
||||
<h2 class="section-heading<%= ' section-heading--spaced' if @sessions.any? %>">Prossime partite</h2>
|
||||
<p class="results-hint">Programmate dall’app: 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><%= match.team.name %> vs <%= match.opponent_name %></h3>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
<%= match.category.presence || match.sport&.capitalize %>
|
||||
</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>
|
||||
<% end %>
|
||||
|
||||
<% if @sessions.empty? %>
|
||||
<% if @upcoming_matches.any? %>
|
||||
<div class="empty-state empty-state--soft">
|
||||
<p><strong>Nessuna diretta in questo momento.</strong></p>
|
||||
<p>Torna all’orario indicato: quando la squadra avvierà lo streaming, la partita comparirà in «In diretta adesso».</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="empty-hero">
|
||||
<div class="empty-hero-copy">
|
||||
<h2>Nessuna diretta adesso — ma puoi già organizzarti</h2>
|
||||
<p>
|
||||
Se la tua squadra usa Match Live TV, dal telefono si programma la partita con data e ora.
|
||||
Genitori e parenti trovano qui la diretta quando il fischio d’inizio è vicino.
|
||||
</p>
|
||||
<ul class="empty-hero-list">
|
||||
<li>Programma la gara dall’app (senza avviare subito la diretta)</li>
|
||||
<li>La partita compare in «Prossime partite» con orario e campo</li>
|
||||
<li>Lo streaming parte solo quando premi avvio in palestra</li>
|
||||
</ul>
|
||||
<p class="empty-hero-cta">
|
||||
<%= link_to "Registra la tua squadra", public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to "Scopri come funziona", public_features_path, class: "btn btn-secondary" %>
|
||||
</p>
|
||||
</div>
|
||||
<aside class="demo-live-card" aria-label="Esempio di diretta attiva">
|
||||
<p class="demo-label">Esempio — così appare una diretta attiva</p>
|
||||
<article class="live-card live-card--demo">
|
||||
<h3>Tigers Volley vs ASD Eagles</h3>
|
||||
<p class="meta">PalaTigers · Match Live TV</p>
|
||||
<p class="card-score">
|
||||
<span class="card-sets">Set 2 · Set vinti 1-0</span>
|
||||
<span class="card-points">18 - 16</span>
|
||||
</p>
|
||||
<div class="badges">
|
||||
<span class="badge badge-on-air">In onda</span>
|
||||
</div>
|
||||
<span class="btn-watch btn-watch--demo">Guarda diretta →</span>
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Match Live TV<% end %>
|
||||
<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
|
||||
<% content_for :meta_description do %>Segui in diretta live <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %>
|
||||
<% content_for :robots, "noindex, follow" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<% unless @stream_closed %>
|
||||
|
||||
83
backend/app/views/public/pages/faq.html.erb
Normal file
@@ -0,0 +1,83 @@
|
||||
<% content_for :title, "FAQ — Diretta live partite giovanili | Match Live TV" %>
|
||||
<% content_for :meta_description, "Domande frequenti su streaming partite giovanili: come guardare la diretta senza app, costi, archivio, pallavolo Under 16, inviti staff e piani Free e Premium." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_faq_path) %>
|
||||
|
||||
<div class="wrap seo-page">
|
||||
<h1 style="margin-top:24px">Domande frequenti sullo streaming delle partite giovanili</h1>
|
||||
<p class="seo-lead">
|
||||
Risposte per genitori, allenatori e dirigenti che vogliono mandare in diretta live le partite
|
||||
di pallavolo, calcio e altri sport dilettantistici.
|
||||
</p>
|
||||
|
||||
<div class="faq-list">
|
||||
<details class="faq-item" open>
|
||||
<summary>Come guardo la partita di mio figlio senza installare app?</summary>
|
||||
<p>
|
||||
La società condivide un link (via WhatsApp, email o gruppo squadra). Apri il link dal telefono o dal computer:
|
||||
vedi la <strong>diretta live</strong> nel browser, senza registrarti. Funziona per nonni, parenti e amici lontani.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Chi può avviare la diretta dalla palestra?</summary>
|
||||
<p>
|
||||
Coach, dirigenti o volontari invitati dalla società. Usano l’app Match Live TV sul telefono per filmare e,
|
||||
se serve, un secondo telefono per aggiornare il punteggio. La diretta parte solo quando premo avvio in palestra.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>È adatto alla pallavolo giovanile (Under 14, Under 16)?</summary>
|
||||
<p>
|
||||
Sì. Il punteggio a set è pensato per la pallavolo; puoi personalizzare regole per tornei particolari.
|
||||
Scopri la pagina dedicata: <%= link_to "Match Live TV per pallavolo giovanile", public_pallavolo_path %>.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Cosa succede se perdo la diretta?</summary>
|
||||
<p>
|
||||
Con i piani Premium Light o Full la partita viene salvata in archivio (30 o 90 giorni) e puoi rivederla dal sito.
|
||||
Il piano Free non include replay su server, ma la diretta resta gratuita per chi trasmette e per chi guarda.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Quanto costa per la società?</summary>
|
||||
<p>
|
||||
Puoi iniziare con il piano <strong>Free</strong> (limiti su staff e una diretta alla volta).
|
||||
Premium Light e Full aggiungono più partite in parallelo, archivio e YouTube.
|
||||
<%= link_to "Confronta i prezzi", public_prezzi_path %>.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Posso programmare una partita prima del giorno gara?</summary>
|
||||
<p>
|
||||
Sì, dall’app si imposta data e ora: la partita compare in «Prossime partite» sul sito.
|
||||
Lo streaming parte solo quando lo staff avvia la trasmissione in palestra.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>La diretta va anche su YouTube?</summary>
|
||||
<p>
|
||||
Con Premium Light puoi mandarla sul canale Match TV Light; con Premium Full anche sul canale YouTube della società.
|
||||
Chi preferisce resta sul link Match Live TV, comodo per le famiglie.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details class="faq-item">
|
||||
<summary>Devo aprire porte sul router o avere attrezzatura da TV?</summary>
|
||||
<p>
|
||||
No per chi guarda. Per la società che trasmette basta lo smartphone e una buona connessione in palestra;
|
||||
lo staff tecnico della piattaforma gestisce l’infrastruttura. Niente camere broadcast o mixer.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<p style="text-align:center;margin:40px 0">
|
||||
<%= link_to "Registra la squadra", public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to "Guarda le dirette", public_live_index_path, class: "btn btn-secondary" %>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,24 +1,25 @@
|
||||
<% content_for :title, "Funzionalità — Match Live TV" %>
|
||||
<% content_for :meta_description, "Camera, regia, punteggio live, gestione staff, archivio gare e YouTube con Premium Light e Full." %>
|
||||
<% content_for :title, "Funzionalità streaming partite giovanili — Match Live TV" %>
|
||||
<% content_for :meta_description, "App per diretta live da telefono: filma la partita, aggiorna il punteggio, condividi il link con genitori e nonni. Archivio gare e piani per società sportive giovanili." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_features_path) %>
|
||||
|
||||
<div class="wrap">
|
||||
<h1 style="margin-top:24px">Funzionalità</h1>
|
||||
<p style="color:#aaa;max-width:640px">Tutto ciò che serve per trasmettere partite giovanili in modo professionale, senza complessità da broadcaster TV.</p>
|
||||
<p style="color:#aaa;max-width:640px">Tutto ciò che serve per far seguire le partite a chi non può essere in palestra: semplice per coach e volontari, comodo per genitori, nonni e parenti.</p>
|
||||
|
||||
<section class="section">
|
||||
<h2>Per la società e lo staff</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<h3>Staff con email</h3>
|
||||
<p>Delega trasmissione e regia a coach e volontari: ognuno ha credenziali personali.</p>
|
||||
<h3>Inviti con la propria email</h3>
|
||||
<p>Il presidente o l’owner invita chi filma e chi aggiorna il punteggio: ognuno accede con la sua email, senza condividere password.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Revoca accessi</h3>
|
||||
<p>L'owner rimuove membri dello staff e annulla richieste pendenti dalla dashboard web.</p>
|
||||
<h3>Gestione accessi</h3>
|
||||
<p>Puoi togliere l’accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Limiti per piano</h3>
|
||||
<p>Posti staff (trasmissione e regia) e partite concorrenti scalano con Free, Premium Light e Premium Full.</p>
|
||||
<h3>Piani su misura</h3>
|
||||
<p>Con Free, Premium Light o Premium Full aumenti quante persone possono aiutare in diretta e quante partite possono andare in parallelo.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -27,32 +28,32 @@
|
||||
<h2>In campo e in diretta</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<h3>Modalità camera</h3>
|
||||
<p>Streaming RTMP resiliente: la diretta continua anche con reti instabili.</p>
|
||||
<h3>Filma dal telefono</h3>
|
||||
<p>Appoggi il telefono e avvii la diretta: anche con il 4G debole la trasmissione resta stabile il più possibile, senza impazzire con cavi o mixer.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Regia separata</h3>
|
||||
<p>Secondo dispositivo per aggiornare il punteggio via QR pairing.</p>
|
||||
<h3>Punteggio da un secondo telefono</h3>
|
||||
<p>Un altro volontario aggiorna set e punti da un secondo cellulare: basta inquadrare il codice che compare sullo schermo di chi filma.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Pagina pubblica /live</h3>
|
||||
<p>Genitori e tifosi guardano dal browser senza installare nulla.</p>
|
||||
<h3>Link per famiglie e tifosi</h3>
|
||||
<p>Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Archivio replay</h3>
|
||||
<p>Premium Light/Full: salvataggio server (30 o 90 giorni) e download su telefono.</p>
|
||||
<h3>Archivio partite</h3>
|
||||
<p>Con Premium Light o Full le gare restano salvate sul server (30 o 90 giorni) e puoi scaricarle sul telefono quando serve.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>YouTube Match TV Light</h3>
|
||||
<p>Premium Light: le dirette sono visibili anche sul canale YouTube Match TV Light.</p>
|
||||
<h3>Anche su YouTube (Match TV Light)</h3>
|
||||
<p>Con Premium Light la diretta è visibile anche sul canale YouTube Match TV Light, oltre al link Match Live TV.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>YouTube della società</h3>
|
||||
<p>Premium Full: relay verso il canale YouTube del club.</p>
|
||||
<h3>Canale YouTube della società</h3>
|
||||
<p>Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Sito della società</h3>
|
||||
<p>Integrazione embed sul sito del club — in arrivo con Premium Full.</p>
|
||||
<h3>Sul sito della società</h3>
|
||||
<p>Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<% content_for :title, "Match Live TV — Ogni partita, ogni evento, per chi non può esserci" %>
|
||||
<% content_for :meta_description, "Trasmetti le partite dallo smartphone. Nonni, parenti e amici guardano dal browser, senza app. Se te la perdi, la ritrovi nell'archivio." %>
|
||||
<% 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 :canonical_url, seo_absolute_url(root_path) %>
|
||||
|
||||
<section class="hero hero-split">
|
||||
<div class="wrap hero-grid">
|
||||
@@ -41,7 +42,7 @@
|
||||
</section>
|
||||
|
||||
<section class="section wrap">
|
||||
<h2>Come funziona</h2>
|
||||
<h2>Come funziona lo streaming per squadre giovanili</h2>
|
||||
<div class="steps">
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
@@ -79,10 +80,30 @@
|
||||
</section>
|
||||
|
||||
<section class="section wrap plans-teaser">
|
||||
<h2>Piani per ogni esigenza</h2>
|
||||
<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>
|
||||
<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" %>
|
||||
</div>
|
||||
<%= link_to "Confronta i piani", 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>
|
||||
<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 all’estero: 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 %>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
67
backend/app/views/public/pages/pallavolo.html.erb
Normal file
@@ -0,0 +1,67 @@
|
||||
<% content_for :title, "Diretta live pallavolo giovanile — Under 14, Under 16 | Match Live TV" %>
|
||||
<% content_for :meta_description, "Streaming partite di pallavolo giovanile da telefono: diretta live per genitori e nonni, punteggio a set, link da condividere. Ideale per società Under 14, Under 16 e Under 18." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_pallavolo_path) %>
|
||||
|
||||
<div class="wrap seo-page">
|
||||
<h1 style="margin-top:24px">Match Live TV per la pallavolo giovanile</h1>
|
||||
<p class="seo-lead">
|
||||
La soluzione per società, coach e genitori che vogliono una <strong>diretta live</strong> affidabile
|
||||
delle partite <strong>Under 14, Under 16, Under 18</strong> e settori giovanili — senza diventare telecronisti.
|
||||
</p>
|
||||
|
||||
<section class="seo-prose">
|
||||
<h2>Perché la pallavolo giovanile ha bisogno di uno streaming semplice</h2>
|
||||
<p>
|
||||
In palestra i genitori non sono sempre tutti in tribuna: turni di lavoro, fratelli piccoli, trasferte.
|
||||
Un nonno in un’altra città o un parente all’estero vuole comunque vedere il tie-break del terzo set.
|
||||
Match Live TV nasce per questo: <strong>streaming partite pallavolo giovanile</strong> dal telefono,
|
||||
con punteggio aggiornato e link immediato da mandare su WhatsApp.
|
||||
</p>
|
||||
|
||||
<h2>Cosa fa il coach in due minuti</h2>
|
||||
<ul>
|
||||
<li>Programma la partita con data, avversario e campo dall’app</li>
|
||||
<li>Il giorno gara appoggia il telefono e avvia la diretta</li>
|
||||
<li>Un volontario può aggiornare set e punti da un secondo cellulare</li>
|
||||
<li>Condivide il link con il gruppo genitori: tutti guardano dal browser</li>
|
||||
</ul>
|
||||
|
||||
<h2>Cosa vede la famiglia a casa</h2>
|
||||
<p>
|
||||
Nessuna app da installare. Apri il link e segui la partita in diretta live: nome squadre, punteggio,
|
||||
stato del set. Se hai perso l’inizio, con Premium la gara resta in archivio per rivederla quando vuoi.
|
||||
</p>
|
||||
|
||||
<h2>Pallavolo: punteggio e regole</h2>
|
||||
<p>
|
||||
Set da vincere 2 o 3, punti 25/21/15 configurabili per tornei non standard. La regia è separata dalla ripresa:
|
||||
in campo resta concentrato sul gioco, non sul telefono.
|
||||
</p>
|
||||
|
||||
<h2>Altri sport giovanili</h2>
|
||||
<p>
|
||||
La stessa piattaforma funziona anche per calcio, basket e sport di squadra dilettantistici.
|
||||
La pallavolo resta lo sport con cui molte società iniziano perché il punteggio a set è già integrato.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<div class="features-grid" style="margin-top:32px">
|
||||
<div class="feature-card">
|
||||
<h3>Genitori e nonni</h3>
|
||||
<p>Link su WhatsApp, visione da telefono o TV con browser. Zero registrazione per chi guarda.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Società e staff</h3>
|
||||
<p>Inviti con email, più operatori con Premium, revoca accessi dalla dashboard web.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Archivio gare</h3>
|
||||
<p>Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="text-align:center;margin:40px 0">
|
||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to "Domande frequenti", public_faq_path, class: "btn btn-secondary" %>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "Prezzi — Match Live TV" %>
|
||||
<% content_for :title, "Prezzi streaming partite giovanili — Match Live TV" %>
|
||||
<% content_for :meta_description, "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_prezzi_path) %>
|
||||
|
||||
<div class="wrap" style="padding-top:24px">
|
||||
<h1>Piani per la tua squadra</h1>
|
||||
|
||||
@@ -1,9 +1,188 @@
|
||||
<% content_for :title, "Privacy — Match Live TV" %>
|
||||
<div class="wrap" style="padding-top:24px">
|
||||
<h1>Informativa privacy</h1>
|
||||
<div class="card">
|
||||
<p>Match Live TV tratta i dati necessari per account, gestione staff e streaming (email, nome, dati di utilizzo delle sessioni).</p>
|
||||
<p>Per richieste o cancellazione account contattare il gestore del servizio all'indirizzo indicato nella dashboard della squadra.</p>
|
||||
<p style="color:#888;font-size:0.9rem">Documento provvisorio — da completare con i dati del titolare del trattamento.</p>
|
||||
</div>
|
||||
<% content_for :title, "Informativa privacy — Match Live TV" %>
|
||||
<% content_for :meta_description, "Informativa privacy GDPR di Match Live TV: dati account, streaming partite giovanili, immagini di minori, pagamenti Stripe e diritti degli interessati." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_privacy_path) %>
|
||||
|
||||
<div class="wrap legal-doc">
|
||||
<h1>Informativa sul trattamento dei dati personali</h1>
|
||||
<p class="legal-meta">Ultimo aggiornamento: <%= legal_last_updated %> · Ai sensi del Regolamento (UE) 2016/679 (GDPR)</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Titolare del trattamento</h2>
|
||||
<p>
|
||||
Il titolare del trattamento dei dati personali raccolti tramite il sito e i servizi Match Live TV è:
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong><%= MatchLiveTv.privacy_controller_name %></strong></li>
|
||||
<li>Indirizzo: <%= MatchLiveTv.privacy_controller_address %></li>
|
||||
<li>Email per privacy e diritti: <a href="mailto:<%= MatchLiveTv.privacy_controller_email %>"><%= MatchLiveTv.privacy_controller_email %></a></li>
|
||||
<% if MatchLiveTv.privacy_controller_vat.present? %>
|
||||
<li>Partita IVA / CF: <%= MatchLiveTv.privacy_controller_vat %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<p>Per richieste relative a questa informativa o per esercitare i diritti previsti dal GDPR, scrivi all’indirizzo email indicato.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Ambito di applicazione</h2>
|
||||
<p>La presente informativa si applica a:</p>
|
||||
<ul>
|
||||
<li>visitatori del sito web Match Live TV (pagine informative, elenco dirette, registrazione squadra);</li>
|
||||
<li>utenti registrati (referenti di società sportive, coach, staff invitato);</li>
|
||||
<li>spettatori che accedono alle dirette tramite link (in genere senza creare un account);</li>
|
||||
<li>utilizzo dell’app mobile collegata al servizio.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Tipologie di dati trattati</h2>
|
||||
<p>Possiamo trattare, a seconda dell’uso del servizio:</p>
|
||||
<ul>
|
||||
<li><strong>Dati identificativi e di contatto:</strong> nome, indirizzo email, credenziali di accesso (password conservata in forma crittografata).</li>
|
||||
<li><strong>Dati della squadra:</strong> nome società, sport, partite programmate, avversario, luogo, orari.</li>
|
||||
<li><strong>Dati di streaming e tecnici:</strong> stato sessione, punteggio, telemetria di rete/bitrate, log di connessione al servizio video.</li>
|
||||
<li><strong>Contenuti audiovisivi:</strong> flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato.</li>
|
||||
<li><strong>Dati di pagamento:</strong> per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta.</li>
|
||||
<li><strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Trattamento di immagini e dati di atleti minorenni</h2>
|
||||
<p>
|
||||
Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18).
|
||||
Le dirette possono riprendere <strong>atleti minorenni</strong> identificabili (immagine, voce, numero di maglia, dati di gioco).
|
||||
</p>
|
||||
<p><strong>Chi è responsabile del trattamento delle immagini dei minori</strong></p>
|
||||
<p>
|
||||
La <strong>società sportiva</strong> che avvia la trasmissione, in qualità di organizzatrice dell’evento sportivo,
|
||||
è in primo luogo responsabile di assicurare una base giuridica valida (tipicamente <strong>consenso dei genitori/tutori</strong>,
|
||||
oppure altro titolo previsto dalla legge e dal regolamento della federazione di riferimento) prima di filmare e diffondere le immagini.
|
||||
</p>
|
||||
<p><strong>Ruolo di Match Live TV</strong></p>
|
||||
<p>
|
||||
Il titolare del servizio fornisce la piattaforma tecnica e, per le trasmissioni effettuate dagli utenti autorizzati,
|
||||
può agire come <strong>responsabile del trattamento</strong> su istruzioni della società o, in alcuni casi, come titolare
|
||||
per i trattamenti strettamente necessari all’erogazione del servizio (hosting, sicurezza, fatturazione).
|
||||
</p>
|
||||
<p><strong>Misure richieste alla società sportiva</strong></p>
|
||||
<ul>
|
||||
<li>informare genitori/tutori sull’uso di video in diretta e in archivio;</li>
|
||||
<li>raccogliere e documentare i consensi necessari prima della diretta;</li>
|
||||
<li>limitare la diffusione del link a persone autorizzate (familiari, staff);</li>
|
||||
<li>non pubblicare su canali aperti se non previsto dal consenso o dal regolamento sportivo;</li>
|
||||
<li>richiedere rimozione di registrazioni quando ne ha diritto l’interessato o il tutore.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Le richieste dei genitori/tutori relative a immagini di minori possono essere inoltrate alla società sportiva
|
||||
e, per supporto tecnico o cancellazione da archivio, anche a
|
||||
<a href="mailto:<%= MatchLiveTv.privacy_controller_email %>"><%= MatchLiveTv.privacy_controller_email %></a>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Finalità e basi giuridiche</h2>
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th>Finalità</th><th>Base giuridica (art. 6 GDPR)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>Registrazione account, gestione squadra e staff</td>
|
||||
<td>Esecuzione del contratto / misure precontrattuali</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Erogazione dirette, punteggio live, archivio (piani Premium)</td>
|
||||
<td>Esecuzione del contratto; legittimo interesse alla sicurezza del servizio</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Pagamenti e fatturazione (Stripe)</td>
|
||||
<td>Esecuzione del contratto; obblighi di legge</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Assistenza, sicurezza, prevenzione abusi</td>
|
||||
<td>Legittimo interesse; obblighi di legge</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Comunicazioni di servizio (es. reset password)</td>
|
||||
<td>Esecuzione del contratto</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Marketing diretto (solo se previsto e con consenso ove richiesto)</td>
|
||||
<td>Consenso o legittimo interesse, secondo i casi</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Destinatari e responsabili del trattamento</h2>
|
||||
<p>I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:</p>
|
||||
<ul>
|
||||
<li>fornitore di hosting e infrastruttura cloud/server;</li>
|
||||
<li><strong>Stripe</strong> (pagamenti) — <a href="https://stripe.com/it/privacy" rel="noopener" target="_blank">privacy Stripe</a>;</li>
|
||||
<li>fornitore streaming video (MediaMTX / infrastruttura RTMP-HLS);</li>
|
||||
<li>eventuale integrazione <strong>YouTube</strong> se attivata dalla società (soggetto a policy Google);</li>
|
||||
<li>fornitore email transazionale (invio link reset password, inviti).</li>
|
||||
</ul>
|
||||
<p>I dati non sono venduti a terzi per finalità di profilazione commerciale.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Trasferimenti extra SEE</h2>
|
||||
<p>
|
||||
Alcuni fornitori (es. Stripe, Google/YouTube) possono trattare dati anche fuori dallo Spazio Economico Europeo.
|
||||
In tal caso si applicano garanzie adeguate previste dal GDPR (clausole contrattuali standard, decisioni di adeguatezza).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Conservazione</h2>
|
||||
<ul>
|
||||
<li><strong>Account utente:</strong> per tutta la durata del rapporto contrattuale e, dopo la cessazione, per il tempo necessario a obblighi di legge o contestazioni (in genere fino a 10 anni per documenti contabili ove applicabile).</li>
|
||||
<li><strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta.</li>
|
||||
<li><strong>Log tecnici:</strong> periodo limitato (es. 30–90 giorni) salvo obblighi di sicurezza.</li>
|
||||
<li><strong>Token reset password:</strong> validità massima <%= MatchLiveTv.password_reset_expiry_hours %> ore.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Diritti degli interessati</h2>
|
||||
<p>In qualità di interessato hai diritto di:</p>
|
||||
<ul>
|
||||
<li>accedere ai tuoi dati e ottenerne copia;</li>
|
||||
<li>rettificarli se inesatti;</li>
|
||||
<li>chiederne la cancellazione (nei limiti di legge);</li>
|
||||
<li>limitare o opporti al trattamento, ove applicabile;</li>
|
||||
<li>revocare il consenso senza pregiudicare la liceità del trattamento basato sul consenso prestato prima della revoca;</li>
|
||||
<li>proporre reclamo all’Autorità Garante per la protezione dei dati personali (<a href="https://www.garanteprivacy.it" rel="noopener" target="_blank">garanteprivacy.it</a>).</li>
|
||||
</ul>
|
||||
<p>Per esercitare i diritti: <a href="mailto:<%= MatchLiveTv.privacy_controller_email %>"><%= MatchLiveTv.privacy_controller_email %></a>.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Sicurezza</h2>
|
||||
<p>
|
||||
Adottiamo misure tecniche e organizzative adeguate (accesso autenticato, password crittografate,
|
||||
comunicazioni HTTPS, segregazione ambienti, limitazione accessi staff). Nessun sistema è invulnerabile:
|
||||
se sospetti un accesso non autorizzato, cambia password e contattaci.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Cookie e tecnologie simili</h2>
|
||||
<p>
|
||||
Il sito utilizza cookie tecnici necessari al funzionamento (es. sessione di login) e, in futuro,
|
||||
eventuali strumenti di analisi solo previo consenso ove richiesto. Per dettagli aggiornati contatta il titolare.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>12. Modifiche</h2>
|
||||
<p>
|
||||
Il titolare può aggiornare questa informativa. La data in alto indica l’ultima revisione.
|
||||
In caso di modifiche rilevanti, sarà fornita comunicazione adeguata (es. avviso sul sito o email agli account registrati).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="legal-back"><%= link_to "← Torna alla home", root_path %></p>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,134 @@
|
||||
<% content_for :title, "Termini di servizio — Match Live TV" %>
|
||||
<div class="wrap" style="padding-top:24px">
|
||||
<h1>Termini di servizio</h1>
|
||||
<div class="card">
|
||||
<p>L'uso di Match Live TV implica l'accettazione dei limiti del piano sottoscritto (staff, dirette concorrenti, archivio).</p>
|
||||
<p>Le dirette sono responsabilità della squadra titolare dell'account. È vietato l'uso per contenuti non conformi alle norme sulla protezione dei minori.</p>
|
||||
<p style="color:#888;font-size:0.9rem">Documento provvisorio — da rivedere con consulenza legale.</p>
|
||||
</div>
|
||||
<% content_for :meta_description, "Termini e condizioni d'uso di Match Live TV per società sportive: dirette live, minori, piani, pagamenti Stripe e responsabilità degli utenti." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_termini_path) %>
|
||||
|
||||
<div class="wrap legal-doc">
|
||||
<h1>Termini e condizioni di servizio</h1>
|
||||
<p class="legal-meta">Ultimo aggiornamento: <%= legal_last_updated %></p>
|
||||
|
||||
<section>
|
||||
<h2>1. Oggetto</h2>
|
||||
<p>
|
||||
I presenti Termini regolano l’uso della piattaforma <strong>Match Live TV</strong> (sito web, applicazione mobile
|
||||
e servizi connessi) per la gestione di dirette sportive dilettantistiche/giovanili, punteggio live,
|
||||
condivisione link agli spettatori e funzioni Premium (archivio, YouTube, ecc.).
|
||||
</p>
|
||||
<p>
|
||||
Il servizio è erogato da <strong><%= MatchLiveTv.privacy_controller_name %></strong>
|
||||
(di seguito «Fornitore»). Registrandoti o utilizzando il servizio accetti integralmente questi Termini
|
||||
e l’<%= link_to "informativa privacy", public_privacy_path %>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Chi può usare il servizio</h2>
|
||||
<ul>
|
||||
<li>Il referente che registra la squadra dichiara di avere almeno <strong>18 anni</strong> e poteri per vincolare la società sportiva.</li>
|
||||
<li>Lo staff invitato (trasmissione, regia) usa credenziali personali e si impegna a custodirle.</li>
|
||||
<li>Gli spettatori che aprono il link della diretta di solito non necessitano di account.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Account, piani e pagamenti</h2>
|
||||
<p>
|
||||
Sono disponibili piani Free e Premium (Light / Full) con limiti su staff, dirette concorrenti, archivio e integrazioni.
|
||||
I prezzi sono indicati nella pagina <%= link_to "Prezzi", public_prezzi_path %>.
|
||||
</p>
|
||||
<p>
|
||||
I pagamenti dei piani a pagamento sono gestiti da <strong>Stripe</strong>. Accettando il checkout,
|
||||
accetti anche i termini di Stripe per il pagamento. Il Fornitore non memorizza i dati completi della carta.
|
||||
</p>
|
||||
<p>Rinnovi, disdetta e diritto di recesso seguono quanto comunicato al momento dell’acquisto e la normativa applicabile.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Dirette, contenuti e protezione dei minori</h2>
|
||||
<p><strong>Responsabilità della società sportiva</strong></p>
|
||||
<p>
|
||||
La società che avvia una diretta è responsabile dei contenuti trasmessi (immagini, audio, commenti)
|
||||
e della conformità alle leggi, al regolamento federale e ai consensi necessari, in particolare quando
|
||||
sono ripresi <strong>atleti minorenni</strong>.
|
||||
</p>
|
||||
<p>La società si impegna a:</p>
|
||||
<ul>
|
||||
<li>ottenere e conservare i consensi genitoriali/tutori o altra base giuridica prima della ripresa;</li>
|
||||
<li>informare genitori e atleti su dove e per quanto tempo è visibile la diretta/archivio;</li>
|
||||
<li>non trasmettere contenuti illegali, diffamatori, violenti o non pertinenti all’evento sportivo;</li>
|
||||
<li>rispettare il diritto all’immagine e alla voce di tutte le persone inquadrate.</li>
|
||||
</ul>
|
||||
<p>
|
||||
Il Fornitore può sospendere o interrompere trasmissioni e account in caso di segnalazioni fondate
|
||||
o violazioni gravi, senza pregiudizio di azioni legali.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Uso consentito e divieti</h2>
|
||||
<p>È vietato:</p>
|
||||
<ul>
|
||||
<li>usare il servizio per finalità non sportive o commerciali non autorizzate;</li>
|
||||
<li>aggirare limiti tecnici o di piano (staff, dirette concorrenti);</li>
|
||||
<li>condividere credenziali o pubblicare link su canali che espongono minori a rischi non previsti dal consenso;</li>
|
||||
<li>caricare malware, effettuare attacchi o interferire con l’infrastruttura;</li>
|
||||
<li>rivendere il servizio senza accordo scritto con il Fornitore.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Proprietà intellettuale</h2>
|
||||
<p>
|
||||
Marchi, software, design e documentazione del servizio restano del Fornitore o dei suoi licenzianti.
|
||||
La società sportiva conserva i diritti sui propri contenuti video; concede al Fornitore una licenza
|
||||
limitata a ospitarli, elaborarli e distribuirli tramite la piattaforma per erogare il servizio.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Disponibilità e limitazioni</h2>
|
||||
<p>
|
||||
Il servizio dipende da reti mobili, hardware e fornitori terzi. Non garantiamo assenza assoluta di interruzioni,
|
||||
ritardi o perdita di dati, pur adottando misure ragionevoli. La qualità video può variare in base alla connessione in palestra.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Limitazione di responsabilità</h2>
|
||||
<p>
|
||||
Nei limiti consentiti dalla legge italiana, il Fornitore non risponde di danni indiretti, perdita di profitto,
|
||||
o contenuti trasmessi dagli utenti. La responsabilità complessiva verso un cliente professionale non eccede,
|
||||
ove possibile, l’importo pagato negli ultimi 12 mesi per il piano sottoscritto.
|
||||
</p>
|
||||
<p>Nulla esclude responsabilità per dolo o colpa grave, né diritti inderogabili del consumatore ove applicabili.</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>9. Recesso, sospensione e cancellazione</h2>
|
||||
<p>
|
||||
Puoi richiedere la chiusura dell’account scrivendo a
|
||||
<a href="mailto:<%= MatchLiveTv.privacy_controller_email %>"><%= MatchLiveTv.privacy_controller_email %></a>.
|
||||
Il Fornitore può sospendere l’account per inadempienza, mancato pagamento o violazione dei Termini.
|
||||
Alla cessazione, i dati sono trattati come indicato in privacy (conservazione limitata o cancellazione).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>10. Legge applicabile e foro</h2>
|
||||
<p>
|
||||
I Termini sono regolati dalla <strong>legge italiana</strong>. Per controversie con consumatori si applicano
|
||||
le norme inderogabili di protezione del consumatore e il foro del consumatore ove previsto.
|
||||
Per clienti professionali, foro competente in Italia presso la sede del Fornitore, salvo diverso accordo.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>11. Contatti</h2>
|
||||
<p>
|
||||
<%= MatchLiveTv.privacy_controller_name %><br>
|
||||
<%= MatchLiveTv.privacy_controller_address %><br>
|
||||
Email: <a href="mailto:<%= MatchLiveTv.privacy_controller_email %>"><%= MatchLiveTv.privacy_controller_email %></a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<p class="legal-back"><%= link_to "← Torna alla home", root_path %> · <%= link_to "Privacy", public_privacy_path %></p>
|
||||
</div>
|
||||
|
||||
17
backend/app/views/public/password_resets/edit.html.erb
Normal file
@@ -0,0 +1,17 @@
|
||||
<% content_for :title, "Nuova password — Match Live TV" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>Scegli una nuova password</h1>
|
||||
<div class="card">
|
||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||
<%= hidden_field_tag :token, @token %>
|
||||
<%= label_tag :password, "Nuova password (min. 8 caratteri)" %>
|
||||
<%= password_field_tag :password, required: true, minlength: 8, autocomplete: "new-password" %>
|
||||
<%= label_tag :password_confirmation, "Conferma password" %>
|
||||
<%= password_field_tag :password_confirmation, required: true, autocomplete: "new-password" %>
|
||||
<%= submit_tag "Salva password", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer"><%= link_to "Torna al login", public_login_path %></p>
|
||||
</div>
|
||||
</section>
|
||||
15
backend/app/views/public/password_resets/new.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<% content_for :title, "Password dimenticata — Match Live TV" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>Password dimenticata</h1>
|
||||
<p class="auth-lead">Inserisci l’email dell’account: ti invieremo un link per reimpostare la password.</p>
|
||||
<div class="card">
|
||||
<%= form_with url: public_password_forgot_path, method: :post, local: true do %>
|
||||
<%= label_tag :email, "Email" %>
|
||||
<%= email_field_tag :email, params[:email], required: true, autocomplete: "email" %>
|
||||
<%= submit_tag "Invia link di reset", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer"><%= link_to "Torna al login", public_login_path %></p>
|
||||
</div>
|
||||
</section>
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "Registrati — Match Live TV" %>
|
||||
<% content_for :title, "Registra la squadra — Match Live TV" %>
|
||||
<% content_for :meta_description, "Iscrivi la tua società sportiva giovanile su Match Live TV: dirette live da telefono, link per genitori e piani Free o Premium." %>
|
||||
<% content_for :robots, "noindex, follow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>Registra la squadra</h1>
|
||||
@@ -13,6 +15,18 @@
|
||||
<%= f.password_field :password, required: true, minlength: 8 %>
|
||||
<%= f.label :password_confirmation, "Conferma password" %>
|
||||
<%= f.password_field :password_confirmation, required: true %>
|
||||
<label class="legal-accept">
|
||||
<%= check_box_tag :accept_terms, "1", false, required: true %>
|
||||
<span class="legal-accept-text">
|
||||
Ho letto e accetto l’<%= link_to "informativa privacy", public_privacy_path, target: "_blank", rel: "noopener" %>
|
||||
e i <%= link_to "termini di servizio", public_termini_path, target: "_blank", rel: "noopener" %>.
|
||||
</span>
|
||||
</label>
|
||||
<p class="legal-accept-hint">
|
||||
Registrandoti dichiari di essere maggiorenne e, se operi per una società sportiva con atleti minorenni,
|
||||
di avere legittima facoltà (es. dirigente, coach autorizzato) e di rispettare le regole su immagini dei minori
|
||||
descritte in privacy.
|
||||
</p>
|
||||
<%= f.submit "Continua", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer">Hai già un account? <%= link_to "Accedi", public_login_path %></p>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "Replay — #{@match.team.name} vs #{@match.opponent_name}" %>
|
||||
<% content_for :meta_description, "Replay partita #{@match.team.name} vs #{@match.opponent_name} su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap">
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "Accedi — Match Live TV" %>
|
||||
<% content_for :meta_description, "Accesso area riservata per staff e società registrate su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>Accedi</h1>
|
||||
@@ -7,7 +9,8 @@
|
||||
<%= label_tag :email, "Email" %>
|
||||
<%= email_field_tag :email, params[:email], required: true %>
|
||||
<%= label_tag :password, "Password" %>
|
||||
<%= password_field_tag :password, required: true %>
|
||||
<%= password_field_tag :password, required: true, autocomplete: "current-password" %>
|
||||
<p class="auth-forgot"><%= link_to "Password dimenticata?", public_password_forgot_path %></p>
|
||||
<%= submit_tag "Entra", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer">Nuovo? <%= link_to "Registrati", public_signup_path %></p>
|
||||
|
||||
10
backend/app/views/public/sitemap/show.xml.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<% @entries.each do |entry| %>
|
||||
<url>
|
||||
<loc><%= entry[:loc] %></loc>
|
||||
<changefreq><%= entry[:changefreq] %></changefreq>
|
||||
<priority><%= entry[:priority] %></priority>
|
||||
</url>
|
||||
<% end %>
|
||||
</urlset>
|
||||
@@ -1,4 +1,5 @@
|
||||
<% content_for :title, "Abbonamento — #{@team.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Abbonamento</h1>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "#{@team.name} — Dashboard" %>
|
||||
<% content_for :meta_description, "Dashboard squadra #{@team.name} su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1><%= @team.name %></h1>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<% content_for :title, "Aggiungi staff — #{@team.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Aggiungi staff</h1>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<% content_for :title, "Crea squadra — Match Live TV" %>
|
||||
<% content_for :meta_description, "Configura la squadra dopo la registrazione su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>La tua squadra</h1>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<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 "Termini", public_termini_path %>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div class="site-chrome">
|
||||
<div class="site-masthead">
|
||||
<div class="wrap mast-inner">
|
||||
<%= link_to root_path, class: "brand" do %>Match <span>Live TV</span><% end %>
|
||||
<%= link_to root_path, class: "mast-brand", aria: { label: "Match Live Tv" } do %>
|
||||
<img class="mast-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||
<span class="brand">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>
|
||||
@@ -17,6 +20,7 @@
|
||||
<%= 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) %>
|
||||
<div class="nav-actions">
|
||||
<% if logged_in? %>
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
<% description = content_for?(:meta_description) ? yield(:meta_description) : "Trasmetti le partite dal telefono. Parenti e nonni guardano dal browser. Se te la perdi, la ritrovi nell'archivio." %>
|
||||
<% 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" %>
|
||||
<meta name="description" content="<%= description %>">
|
||||
<meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : 'Match Live TV' %>">
|
||||
<meta name="robots" content="<%= seo_robots_content %>">
|
||||
<link rel="canonical" href="<%= seo_canonical_url %>">
|
||||
<meta property="og:site_name" content="Match Live TV">
|
||||
<meta property="og:title" content="<%= page_title %>">
|
||||
<meta property="og:description" content="<%= description %>">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:url" content="<%= request.original_url %>">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<meta property="og:url" content="<%= seo_canonical_url %>">
|
||||
<meta property="og:locale" content="it_IT">
|
||||
<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 name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="<%= page_title %>">
|
||||
<meta name="twitter:description" content="<%= description %>">
|
||||
<meta name="twitter:image" content="<%= seo_og_image_url %>">
|
||||
<link rel="icon" href="/favicon-32.png?v=3" type="image/png" sizes="32x32">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=3" sizes="180x180">
|
||||
|
||||
11
backend/app/views/user_mailer/password_reset.html.erb
Normal file
@@ -0,0 +1,11 @@
|
||||
<p>Ciao <%= @user.name %>,</p>
|
||||
|
||||
<p>Hai richiesto di reimpostare la password del tuo account Match Live TV.</p>
|
||||
|
||||
<p>
|
||||
<a href="<%= @reset_url %>">Clicca qui per scegliere una nuova password</a>
|
||||
</p>
|
||||
|
||||
<p>Il link è valido per <%= @expiry_hours %> ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata.</p>
|
||||
|
||||
<p style="color:#666;font-size:12px">Match Live TV — <%= MatchLiveTv.privacy_controller_email %></p>
|
||||
10
backend/app/views/user_mailer/password_reset.text.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
Ciao <%= @user.name %>,
|
||||
|
||||
Hai richiesto di reimpostare la password del tuo account Match Live TV.
|
||||
|
||||
Apri questo link (valido <%= @expiry_hours %> ore):
|
||||
<%= @reset_url %>
|
||||
|
||||
Se non hai richiesto tu il reset, ignora questa email.
|
||||
|
||||
Match Live TV — <%= MatchLiveTv.privacy_controller_email %>
|
||||
@@ -31,13 +31,11 @@ Rails.application.configure do
|
||||
# Store uploaded files on the local file system (see config/storage.yml for options).
|
||||
config.active_storage.service = :local
|
||||
|
||||
# Don't care if the mailer can't send.
|
||||
config.action_mailer.raise_delivery_errors = false
|
||||
|
||||
# Disable caching for Action Mailer templates even if Action Controller
|
||||
# caching is enabled.
|
||||
config.action_mailer.raise_delivery_errors = true
|
||||
config.action_mailer.perform_caching = false
|
||||
|
||||
config.action_mailer.delivery_method = :file
|
||||
config.action_mailer.file_settings = { location: Rails.root.join("tmp", "mail") }
|
||||
config.action_mailer.perform_deliveries = true
|
||||
config.action_mailer.default_url_options = { host: "localhost", port: 3000 }
|
||||
|
||||
# Print deprecation notices to the Rails logger.
|
||||
|
||||
@@ -42,6 +42,23 @@ Rails.application.configure do
|
||||
config.log_level = ENV.fetch("RAILS_LOG_LEVEL", "info")
|
||||
|
||||
config.action_mailer.perform_caching = false
|
||||
config.action_mailer.default_url_options = {
|
||||
host: URI.parse(ENV.fetch("APP_PUBLIC_URL", "https://matchlivetv.eminux.it")).host,
|
||||
protocol: URI.parse(ENV.fetch("APP_PUBLIC_URL", "https://matchlivetv.eminux.it")).scheme
|
||||
}
|
||||
config.action_mailer.raise_delivery_errors = true
|
||||
|
||||
if ENV["SMTP_ADDRESS"].present?
|
||||
config.action_mailer.delivery_method = :smtp
|
||||
config.action_mailer.smtp_settings = {
|
||||
address: ENV["SMTP_ADDRESS"],
|
||||
port: ENV.fetch("SMTP_PORT", 587).to_i,
|
||||
user_name: ENV["SMTP_USERNAME"],
|
||||
password: ENV["SMTP_PASSWORD"],
|
||||
authentication: ENV.fetch("SMTP_AUTH", "plain"),
|
||||
enable_starttls_auto: ENV.fetch("SMTP_STARTTLS", "true") == "true"
|
||||
}
|
||||
end
|
||||
config.i18n.fallbacks = true
|
||||
config.active_support.report_deprecations = false
|
||||
config.active_record.dump_schema_after_migration = false
|
||||
|
||||
@@ -51,5 +51,29 @@ module MatchLiveTv
|
||||
def stripe_enabled?
|
||||
stripe_secret_key.present?
|
||||
end
|
||||
|
||||
def privacy_controller_name
|
||||
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Gestore del servizio Match Live TV")
|
||||
end
|
||||
|
||||
def privacy_controller_email
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||
end
|
||||
|
||||
def privacy_controller_address
|
||||
ENV.fetch("PRIVACY_CONTROLLER_ADDRESS", "Indirizzo del titolare — configurare PRIVACY_CONTROLLER_ADDRESS")
|
||||
end
|
||||
|
||||
def privacy_controller_vat
|
||||
ENV["PRIVACY_CONTROLLER_VAT"].presence
|
||||
end
|
||||
|
||||
def mail_from
|
||||
ENV.fetch("MAILER_FROM", "Match Live TV <noreply@matchlivetv.it>")
|
||||
end
|
||||
|
||||
def password_reset_expiry_hours
|
||||
ENV.fetch("PASSWORD_RESET_EXPIRY_HOURS", "2").to_i
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -73,9 +73,12 @@ Rails.application.routes.draw do
|
||||
get "replay/:id", to: "public/replay#show", as: :public_replay
|
||||
|
||||
root to: "public/pages#home"
|
||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||
|
||||
scope module: :public, as: :public do
|
||||
get "funzionalita", to: "pages#features", as: :features
|
||||
get "faq", to: "pages#faq", as: :faq
|
||||
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
|
||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||
get "pricing", to: redirect("/prezzi")
|
||||
get "privacy", to: "pages#privacy", as: :privacy
|
||||
@@ -85,6 +88,10 @@ Rails.application.routes.draw do
|
||||
get "login", to: "sessions#new"
|
||||
post "login", to: "sessions#create"
|
||||
delete "logout", to: "sessions#destroy"
|
||||
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
|
||||
patch "password/reset", to: "password_resets#update"
|
||||
get "teams/new", to: "teams#new"
|
||||
post "teams", to: "teams#create"
|
||||
get "teams/:id/dashboard", to: "teams#dashboard", as: :team_dashboard
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class AddPasswordResetToUsers < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :users, :password_reset_digest, :string
|
||||
add_column :users, :password_reset_sent_at, :datetime
|
||||
add_index :users, :password_reset_digest, unique: true
|
||||
end
|
||||
end
|
||||
5
backend/db/schema.rb
generated
@@ -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_05_27_130000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_27_140000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -192,7 +192,10 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_130000) do
|
||||
t.string "role", default: "coach", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.string "password_reset_digest"
|
||||
t.datetime "password_reset_sent_at"
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["password_reset_digest"], name: "index_users_on_password_reset_digest", unique: true
|
||||
end
|
||||
|
||||
create_table "youtube_credentials", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
|
||||
BIN
backend/public/apple-touch-icon.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
backend/public/favicon-32.png
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
@@ -206,6 +206,116 @@
|
||||
margin: -12px 0 20px;
|
||||
}
|
||||
|
||||
.live-main .section-heading {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: #ccc;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.live-main .section-heading--spaced {
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
.live-main .badge-scheduled {
|
||||
background: #1565c0;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.live-main .live-card--upcoming {
|
||||
border-color: #2a3a4a;
|
||||
}
|
||||
|
||||
.live-main .live-card--upcoming:hover {
|
||||
border-color: #1565c0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.live-main .upcoming-when {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #9ecbff;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.live-main .empty-state--soft {
|
||||
margin-top: 28px;
|
||||
padding: 28px 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.live-main .empty-state--soft p {
|
||||
margin: 0 0 10px;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.live-main .empty-hero {
|
||||
display: grid;
|
||||
grid-template-columns: 1.1fr 1fr;
|
||||
gap: 24px;
|
||||
margin-top: 8px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.live-main .empty-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.live-main .empty-hero-copy h2 {
|
||||
font-size: 1.35rem;
|
||||
margin: 0 0 12px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.live-main .empty-hero-copy > p {
|
||||
color: #aaa;
|
||||
margin: 0 0 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.live-main .empty-hero-list {
|
||||
margin: 0 0 20px;
|
||||
padding-left: 1.2rem;
|
||||
color: #aaa;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.live-main .empty-hero-cta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.live-main .demo-live-card {
|
||||
background: #14141c;
|
||||
border: 1px dashed #333;
|
||||
border-radius: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.live-main .demo-label {
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: #888;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
|
||||
.live-main .live-card--demo {
|
||||
opacity: 0.92;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.live-main .btn-watch--demo {
|
||||
cursor: default;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.live-main .replay-video {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
|
||||
BIN
backend/public/logo.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
@@ -22,13 +22,32 @@ a:hover { text-decoration: underline; }
|
||||
.mast-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-start;
|
||||
gap: 16px;
|
||||
padding-top: 14px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
.brand { font-weight: 800; font-size: 1.15rem; letter-spacing: 0.03em; color: #fff; text-decoration: none; flex-shrink: 0; }
|
||||
.mast-inner .nav-toggle { margin-left: auto; }
|
||||
.brand { font-weight: 800; font-size: 1.15rem; letter-spacing: 0.03em; color: #fff; flex-shrink: 0; }
|
||||
.brand span { color: #e53935; }
|
||||
.mast-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
text-decoration: none;
|
||||
line-height: 1;
|
||||
}
|
||||
.mast-brand:hover { opacity: 0.92; text-decoration: none; }
|
||||
.mast-brand:hover .brand { color: #fff; }
|
||||
.mast-brand-logo {
|
||||
display: block;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.nav-toggle {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
@@ -485,10 +504,99 @@ body.nav-menu-open { overflow: hidden; }
|
||||
.compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||
.compare-table th { color: #aaa; font-weight: 600; }
|
||||
.card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
|
||||
.seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; }
|
||||
.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; }
|
||||
.seo-prose h2:first-child { margin-top: 0; }
|
||||
.seo-prose p { margin: 0 0 14px; }
|
||||
.seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; }
|
||||
.seo-prose a { color: #e53935; }
|
||||
.seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; }
|
||||
.faq-list { max-width: 720px; margin: 0 auto; }
|
||||
.faq-item {
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a36;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 18px;
|
||||
}
|
||||
.faq-item summary {
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
padding: 16px 0;
|
||||
list-style: none;
|
||||
}
|
||||
.faq-item summary::-webkit-details-marker { display: none; }
|
||||
.faq-item summary::after {
|
||||
content: "+";
|
||||
float: right;
|
||||
color: #e53935;
|
||||
font-weight: 700;
|
||||
}
|
||||
.faq-item[open] summary::after { content: "−"; }
|
||||
.faq-item p { color: #aaa; margin: 0 0 16px; line-height: 1.55; font-size: 0.95rem; }
|
||||
|
||||
.legal-doc { max-width: 760px; padding-top: 24px; padding-bottom: 48px; color: #bbb; line-height: 1.65; font-size: 0.95rem; }
|
||||
.legal-doc h1 { color: #fff; font-size: 1.6rem; margin-bottom: 8px; }
|
||||
.legal-doc h2 { color: #fff; font-size: 1.1rem; margin: 28px 0 10px; }
|
||||
.legal-doc p { margin: 0 0 12px; }
|
||||
.legal-doc ul { margin: 0 0 14px; padding-left: 1.25rem; }
|
||||
.legal-doc a { color: #e53935; }
|
||||
.legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; }
|
||||
.legal-back { margin-top: 32px; }
|
||||
.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; }
|
||||
.legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||
.legal-table th { background: #14141c; color: #ccc; }
|
||||
.legal-accept {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
margin: 20px 0 10px;
|
||||
font-size: 0.9rem;
|
||||
color: #c8c8d4;
|
||||
line-height: 1.5;
|
||||
cursor: pointer;
|
||||
}
|
||||
.legal-accept input[type="checkbox"] {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
min-width: 18px;
|
||||
margin: 3px 0 0;
|
||||
flex-shrink: 0;
|
||||
accent-color: #e53935;
|
||||
cursor: pointer;
|
||||
}
|
||||
.legal-accept-text { flex: 1; min-width: 0; }
|
||||
.legal-accept-text a { color: #e53935; text-decoration: underline; }
|
||||
.legal-accept-hint {
|
||||
font-size: 0.82rem;
|
||||
color: #9a9aaa;
|
||||
margin: 0 0 18px;
|
||||
line-height: 1.5;
|
||||
padding-left: 30px;
|
||||
}
|
||||
.auth-forgot { margin: 4px 0 14px; font-size: 0.88rem; text-align: right; }
|
||||
.auth-forgot a { color: #e53935; }
|
||||
table.data { width: 100%; border-collapse: collapse; }
|
||||
table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; }
|
||||
input, select { width: 100%; padding: 10px 12px; margin-bottom: 12px; border-radius: 8px; border: 1px solid #333; background: #0a0a0e; color: #fff; }
|
||||
label { display: block; margin-bottom: 4px; font-size: 0.88rem; color: #999; }
|
||||
input, select {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3d3d4a;
|
||||
background: #1c1c26;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
}
|
||||
input:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #e53935;
|
||||
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
|
||||
}
|
||||
input::placeholder { color: #666; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; font-weight: 500; }
|
||||
.auth-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -498,13 +606,23 @@ label { display: block; margin-bottom: 4px; font-size: 0.88rem; color: #999; }
|
||||
padding: 48px 20px 64px;
|
||||
text-align: center;
|
||||
}
|
||||
.auth-page h1 { margin: 0 0 8px; }
|
||||
.auth-page .auth-lead { color: #aaa; margin: 0 0 24px; max-width: 420px; }
|
||||
.auth-page h1 { margin: 0 0 8px; color: #fff; }
|
||||
.auth-page .auth-lead { color: #b8b8c4; margin: 0 0 24px; max-width: 480px; line-height: 1.5; }
|
||||
.auth-page .card {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
max-width: 440px;
|
||||
margin: 0;
|
||||
padding: 28px 26px;
|
||||
text-align: left;
|
||||
background: #16161e;
|
||||
border: 1px solid #3a3a48;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.auth-page .card form .btn-primary {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.auth-page .card-wide { max-width: 480px; }
|
||||
.auth-page .auth-footer { margin-top: 16px; font-size: 0.92rem; color: #aaa; }
|
||||
.auth-page .auth-footer { margin-top: 20px; font-size: 0.92rem; color: #aaa; text-align: center; }
|
||||
|
||||
BIN
backend/public/og-share.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
@@ -1 +1,24 @@
|
||||
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
|
||||
# Match Live TV — https://matchlivetv.eminux.it
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Allow: /live
|
||||
Allow: /funzionalita
|
||||
Allow: /prezzi
|
||||
Allow: /faq
|
||||
Allow: /pallavolo-giovanile
|
||||
|
||||
Disallow: /admin
|
||||
Disallow: /login
|
||||
Disallow: /signup
|
||||
Disallow: /logout
|
||||
Disallow: /password/
|
||||
Disallow: /teams/
|
||||
Disallow: /join/
|
||||
Disallow: /api/
|
||||
Disallow: /webhooks/
|
||||
Disallow: /internal/
|
||||
Disallow: /cable
|
||||
Disallow: /hls/
|
||||
Disallow: /replay/
|
||||
|
||||
Sitemap: https://matchlivetv.eminux.it/sitemap.xml
|
||||
|
||||
BIN
brand/logo-dark-gradient-256.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
brand/logo-dark-gradient.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
brand/logo-white-m-256.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
brand/logo-white-m.png
Normal file
|
After Width: | Height: | Size: 245 KiB |
@@ -5,3 +5,9 @@ JWT_SECRET=matchlivetv_jwt_dev_secret_change_in_prod
|
||||
YOUTUBE_CLIENT_ID=
|
||||
YOUTUBE_CLIENT_SECRET=
|
||||
YOUTUBE_REDIRECT_URI=http://localhost:3000/api/v1/youtube/callback
|
||||
|
||||
PRIVACY_CONTROLLER_NAME=Gestore del servizio Match Live TV (sviluppo)
|
||||
PRIVACY_CONTROLLER_ADDRESS=Indirizzo di sviluppo
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
@@ -28,3 +28,19 @@ STRIPE_PREMIUM_LIGHT_PRICE_ID=
|
||||
STRIPE_PREMIUM_FULL_PRICE_ID=
|
||||
|
||||
RAILS_LOG_LEVEL=info
|
||||
|
||||
# Titolare trattamento (GDPR) — obbligatorio in produzione
|
||||
PRIVACY_CONTROLLER_NAME=Ragione sociale / nome titolare
|
||||
PRIVACY_CONTROLLER_ADDRESS=Indirizzo sede legale
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
PRIVACY_CONTROLLER_VAT=
|
||||
|
||||
# Email transazionali (reset password, inviti)
|
||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||
SMTP_ADDRESS=
|
||||
SMTP_PORT=587
|
||||
SMTP_USERNAME=
|
||||
SMTP_PASSWORD=
|
||||
SMTP_AUTH=plain
|
||||
SMTP_STARTTLS=true
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<uses-feature android:name="android.hardware.microphone" android:required="true" />
|
||||
|
||||
<application
|
||||
android:label="match_live_tv"
|
||||
android:label="@string/app_name"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
||||
|
Before Width: | Height: | Size: 544 B After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 442 B After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 1.0 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 17 KiB |
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Match Live Tv</string>
|
||||
<string name="streaming_notification_channel">Streaming live</string>
|
||||
<string name="streaming_notification_channel_desc">Notifiche durante lo streaming Match Live TV</string>
|
||||
<string name="streaming_notification_title">Match Live TV</string>
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../shared/widgets/primary_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
import 'resume_session_sheet.dart';
|
||||
import 'no_team_onboarding.dart';
|
||||
import 'schedule_match_sheet.dart';
|
||||
import 'team_providers.dart';
|
||||
|
||||
final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
|
||||
@@ -21,7 +22,16 @@ final matchesProvider = FutureProvider<List<MatchModel>>((ref) async {
|
||||
final teams = await ref.read(teamsProvider.future);
|
||||
if (teams.isEmpty) return [];
|
||||
final client = ref.read(apiClientProvider);
|
||||
return client.fetchMatches(teams.first.id);
|
||||
final list = await client.fetchMatches(teams.first.id);
|
||||
list.sort((a, b) {
|
||||
final sa = a.scheduledAt;
|
||||
final sb = b.scheduledAt;
|
||||
if (sa == null && sb == null) return 0;
|
||||
if (sa == null) return 1;
|
||||
if (sb == null) return -1;
|
||||
return sa.compareTo(sb);
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
class MatchesScreen extends ConsumerWidget {
|
||||
@@ -195,9 +205,13 @@ class MatchesScreen extends ConsumerWidget {
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
'Nessuna partita programmata',
|
||||
'Nessuna partita in calendario.\nTocca «Programma partita» per aggiungere data e ora.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -226,11 +240,21 @@ class MatchesScreen extends ConsumerWidget {
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 20, 20, 20 + bottomInset),
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: PrimaryCta(
|
||||
label: 'NUOVA PARTITA',
|
||||
icon: Icons.add,
|
||||
onPressed: () => _createMatch(context, ref),
|
||||
label: 'PROGRAMMA PARTITA',
|
||||
icon: Icons.event_available,
|
||||
onPressed: () => _scheduleMatch(context, ref),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 20 + bottomInset),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _createMatchQuick(context, ref),
|
||||
icon: const Icon(Icons.settings_outlined, size: 18),
|
||||
label: const Text('Crea e configura subito (senza orario)'),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -290,7 +314,70 @@ class MatchesScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createMatch(BuildContext context, WidgetRef ref) async {
|
||||
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
|
||||
final teams = await ref.read(teamsProvider.future);
|
||||
if (teams.isEmpty) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Nessuna squadra disponibile')),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final data = await showScheduleMatchSheet(context);
|
||||
if (data == null || !context.mounted) return;
|
||||
|
||||
try {
|
||||
final client = ref.read(apiClientProvider);
|
||||
final match = await client.createMatch(teams.first.id, {
|
||||
'opponent_name': data.opponentName,
|
||||
'scheduled_at': data.scheduledAt.toUtc().toIso8601String(),
|
||||
if (data.location != null) 'location': data.location,
|
||||
'sport': 'volleyball',
|
||||
'sets_to_win': 3,
|
||||
});
|
||||
ref.invalidate(matchesProvider);
|
||||
if (!context.mounted) return;
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Partita programmata — visibile sul sito')),
|
||||
);
|
||||
|
||||
final configure = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
backgroundColor: AppTheme.surface,
|
||||
title: const Text('Configurare ora?'),
|
||||
content: const Text(
|
||||
'Puoi preparare telefono e regia subito, oppure tornare quando sei in palestra.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('Più tardi'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(backgroundColor: AppTheme.primaryRed),
|
||||
child: const Text('Configura'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (configure == true && context.mounted) {
|
||||
context.push('/setup/${match.id}/1');
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Errore: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _createMatchQuick(BuildContext context, WidgetRef ref) async {
|
||||
final teams = await ref.read(teamsProvider.future);
|
||||
if (teams.isEmpty) {
|
||||
if (context.mounted) {
|
||||
@@ -311,6 +398,18 @@ class MatchesScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _matchStatusLabel(
|
||||
MatchModel match, {
|
||||
required bool canResume,
|
||||
required bool hasSession,
|
||||
}) {
|
||||
if (canResume) return 'RIPRENDI';
|
||||
if (hasSession) return 'IN CORSO';
|
||||
final at = match.scheduledAt;
|
||||
if (at != null && at.isAfter(DateTime.now())) return 'PROGRAMMATA';
|
||||
return 'AVVIA';
|
||||
}
|
||||
|
||||
class _MatchCard extends StatelessWidget {
|
||||
const _MatchCard({
|
||||
required this.match,
|
||||
@@ -374,7 +473,7 @@ class _MatchCard extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
canResume ? 'RIPRENDI' : (hasSession ? 'IN CORSO' : 'CONFIGURA'),
|
||||
_matchStatusLabel(match, canResume: canResume, hasSession: hasSession),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: hasSession ? AppTheme.primaryRed : AppTheme.textSecondary,
|
||||
fontSize: 11,
|
||||
|
||||
161
mobile/lib/features/matches/schedule_match_sheet.dart
Normal file
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../shared/widgets/primary_cta.dart';
|
||||
|
||||
class ScheduleMatchResult {
|
||||
ScheduleMatchResult({
|
||||
required this.opponentName,
|
||||
required this.scheduledAt,
|
||||
this.location,
|
||||
});
|
||||
|
||||
final String opponentName;
|
||||
final DateTime scheduledAt;
|
||||
final String? location;
|
||||
}
|
||||
|
||||
Future<ScheduleMatchResult?> showScheduleMatchSheet(BuildContext context) {
|
||||
return showModalBottomSheet<ScheduleMatchResult>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: AppTheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (ctx) => const _ScheduleMatchSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
class _ScheduleMatchSheet extends StatefulWidget {
|
||||
const _ScheduleMatchSheet();
|
||||
|
||||
@override
|
||||
State<_ScheduleMatchSheet> createState() => _ScheduleMatchSheetState();
|
||||
}
|
||||
|
||||
class _ScheduleMatchSheetState extends State<_ScheduleMatchSheet> {
|
||||
final _opponentController = TextEditingController();
|
||||
final _locationController = TextEditingController();
|
||||
late DateTime _scheduledAt;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final now = DateTime.now();
|
||||
_scheduledAt = DateTime(now.year, now.month, now.day, now.hour + 2, 0);
|
||||
if (_scheduledAt.isBefore(now)) {
|
||||
_scheduledAt = now.add(const Duration(hours: 2));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_opponentController.dispose();
|
||||
_locationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _pickDateTime() async {
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _scheduledAt,
|
||||
firstDate: DateTime.now().subtract(const Duration(days: 1)),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (date == null || !mounted) return;
|
||||
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(_scheduledAt),
|
||||
);
|
||||
if (time == null) return;
|
||||
|
||||
setState(() {
|
||||
_scheduledAt = DateTime(date.year, date.month, date.day, time.hour, time.minute);
|
||||
});
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final opponent = _opponentController.text.trim();
|
||||
if (opponent.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Inserisci il nome dell’avversario')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.pop(
|
||||
context,
|
||||
ScheduleMatchResult(
|
||||
opponentName: opponent,
|
||||
scheduledAt: _scheduledAt,
|
||||
location: _locationController.text.trim().isEmpty
|
||||
? null
|
||||
: _locationController.text.trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final dateLabel = DateFormat('EEE d MMM yyyy · HH:mm', 'it_IT')
|
||||
.format(_scheduledAt.toLocal());
|
||||
final bottom = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 12, 20, 20 + bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.textSecondary.withValues(alpha: 0.4),
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('Programma partita', style: theme.textTheme.titleLarge),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'La diretta non parte ora: comparirà sul sito con data e ora. '
|
||||
'Avvierai lo streaming dall’app quando sei in palestra.',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: _opponentController,
|
||||
decoration: const InputDecoration(labelText: 'Avversario'),
|
||||
textCapitalization: TextCapitalization.words,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _locationController,
|
||||
decoration: const InputDecoration(labelText: 'Luogo (opzionale)'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('Data e ora', style: theme.textTheme.bodyMedium),
|
||||
subtitle: Text(dateLabel, style: theme.textTheme.titleMedium),
|
||||
trailing: const Icon(Icons.calendar_today, color: AppTheme.primaryRed),
|
||||
onTap: _pickDateTime,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
PrimaryCta(
|
||||
label: 'SALVA IN PROGRAMMA',
|
||||
icon: Icons.event_available,
|
||||
onPressed: _submit,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class MatchLiveTvApp extends ConsumerWidget {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Match Live TV',
|
||||
title: 'Match Live Tv',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
|
||||