Initial commit: monorepo Match Live TV.
Rails API, app Flutter, infrastruttura Docker/MediaMTX, sito marketing e documentazione di deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
25
backend/app/views/admin/dashboard/index.html.erb
Normal file
25
backend/app/views/admin/dashboard/index.html.erb
Normal file
@@ -0,0 +1,25 @@
|
||||
<h2>Sessioni attive</h2>
|
||||
<% if @active_sessions.any? %>
|
||||
<table>
|
||||
<thead><tr><th>Match</th><th>Status</th><th>Iniziata</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @active_sessions.each do |s| %>
|
||||
<tr>
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||
<td><span class="badge live"><%= s.status %></span></td>
|
||||
<td><%= s.started_at %></td>
|
||||
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>Nessuna sessione attiva.</p>
|
||||
<% end %>
|
||||
|
||||
<h2>Squadre</h2>
|
||||
<ul>
|
||||
<% @teams.each do |t| %>
|
||||
<li><%= link_to t.name, admin_team_path(t) %> — <%= t.matches.count %> partite</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
14
backend/app/views/admin/sessions/index.html.erb
Normal file
14
backend/app/views/admin/sessions/index.html.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
<h2>Stream Sessions</h2>
|
||||
<table>
|
||||
<thead><tr><th>Match</th><th>Status</th><th>Disconnects</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @sessions.each do |s| %>
|
||||
<tr>
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||
<td><span class="badge <%= s.live? ? 'live' : 'ended' %>"><%= s.status %></span></td>
|
||||
<td><%= s.disconnection_count %></td>
|
||||
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
21
backend/app/views/admin/sessions/show.html.erb
Normal file
21
backend/app/views/admin/sessions/show.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<h2>Session <%= @session.id %></h2>
|
||||
<p>Status: <strong><%= @session.status %></strong></p>
|
||||
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
|
||||
<% if @session.youtube_broadcast_id %>
|
||||
<p>YouTube: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank">Broadcast</a></p>
|
||||
<% end %>
|
||||
<p>RTMP ingest: <code><%= @session.rtmp_ingest_url %></code></p>
|
||||
|
||||
<h3>Eventi</h3>
|
||||
<table>
|
||||
<thead><tr><th>Tipo</th><th>Quando</th><th>Meta</th></tr></thead>
|
||||
<tbody>
|
||||
<% @events.each do |e| %>
|
||||
<tr>
|
||||
<td><%= e.event_type %></td>
|
||||
<td><%= e.occurred_at %></td>
|
||||
<td><%= e.metadata.to_json %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
13
backend/app/views/admin/teams/index.html.erb
Normal file
13
backend/app/views/admin/teams/index.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<h2>Teams</h2>
|
||||
<table>
|
||||
<thead><tr><th>Nome</th><th>Sport</th><th>YouTube</th></tr></thead>
|
||||
<tbody>
|
||||
<% @teams.each do |t| %>
|
||||
<tr>
|
||||
<td><%= link_to t.name, admin_team_path(t) %></td>
|
||||
<td><%= t.sport %></td>
|
||||
<td><%= t.youtube_credential.present? ? "✓" : "—" %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
10
backend/app/views/admin/teams/show.html.erb
Normal file
10
backend/app/views/admin/teams/show.html.erb
Normal file
@@ -0,0 +1,10 @@
|
||||
<h2><%= @team.name %></h2>
|
||||
<p>Sport: <%= @team.sport %></p>
|
||||
<p>YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %></p>
|
||||
|
||||
<h3>Partite</h3>
|
||||
<ul>
|
||||
<% @matches.each do |m| %>
|
||||
<li><%= m.opponent_name %> — <%= m.scheduled_at %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
32
backend/app/views/layouts/admin.html.erb
Normal file
32
backend/app/views/layouts/admin.html.erb
Normal file
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Match Live TV Admin</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style>
|
||||
:root { --red: #FF2D2D; --bg: #0A0A0A; --card: #1E1E1E; --text: #fff; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--text); margin: 0; padding: 1rem 2rem; }
|
||||
a { color: var(--red); }
|
||||
header { border-bottom: 1px solid #333; padding-bottom: 1rem; margin-bottom: 2rem; }
|
||||
h1 span { color: var(--red); }
|
||||
table { width: 100%; border-collapse: collapse; background: var(--card); border-radius: 8px; overflow: hidden; }
|
||||
th, td { padding: 0.75rem 1rem; text-align: left; border-bottom: 1px solid #333; }
|
||||
.badge { display: inline-block; padding: 0.2rem 0.6rem; border-radius: 4px; font-size: 0.8rem; }
|
||||
.live { background: var(--red); }
|
||||
.ended { background: #555; }
|
||||
nav a { margin-right: 1rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>MATCH <span>LIVE</span> TV — Admin</h1>
|
||||
<nav>
|
||||
<%= link_to "Dashboard", admin_root_path %>
|
||||
<%= link_to "Teams", admin_teams_path %>
|
||||
<%= link_to "Sessions", admin_sessions_path %>
|
||||
</nav>
|
||||
</header>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
13
backend/app/views/layouts/mailer.html.erb
Normal file
13
backend/app/views/layouts/mailer.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
|
||||
<style>
|
||||
/* Email styles need to be inline */
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
1
backend/app/views/layouts/mailer.text.erb
Normal file
1
backend/app/views/layouts/mailer.text.erb
Normal file
@@ -0,0 +1 @@
|
||||
<%= yield %>
|
||||
20
backend/app/views/layouts/marketing.html.erb
Normal file
20
backend/app/views/layouts/marketing.html.erb
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=14">
|
||||
</head>
|
||||
<body>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
<main>
|
||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
</body>
|
||||
</html>
|
||||
20
backend/app/views/layouts/marketing_live.html.erb
Normal file
20
backend/app/views/layouts/marketing_live.html.erb
Normal file
@@ -0,0 +1,20 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=14">
|
||||
<link rel="stylesheet" href="/live.css">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
<main class="live-main">
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
</body>
|
||||
</html>
|
||||
56
backend/app/views/layouts/public.html.erb
Normal file
56
backend/app/views/layouts/public.html.erb
Normal file
@@ -0,0 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font-family: system-ui, sans-serif; background: #0f0f12; color: #f5f5f5; line-height: 1.5; }
|
||||
a { color: #ff6b6b; }
|
||||
.header { border-bottom: 1px solid #2a2a32; background: #14141a; padding: 14px 0; margin-bottom: 24px; }
|
||||
.wrap { max-width: 880px; margin: 0 auto; padding: 0 16px 40px; }
|
||||
.header .wrap { display: flex; justify-content: space-between; align-items: center; }
|
||||
.brand { font-weight: 800; color: #fff; text-decoration: none; }
|
||||
.brand span { color: #e53935; }
|
||||
.flash { padding: 12px 16px; border-radius: 8px; margin-bottom: 16px; }
|
||||
.flash.notice { background: #1b3d2a; color: #a5f0b8; }
|
||||
.flash.alert { background: #3d1b1b; color: #ffb4b4; }
|
||||
.card { background: #1a1a22; border: 1px solid #2a2a32; border-radius: 12px; padding: 20px; margin-bottom: 16px; }
|
||||
.btn { display: inline-block; padding: 12px 20px; border-radius: 8px; font-weight: 700; text-decoration: none; border: none; cursor: pointer; }
|
||||
.btn-primary { background: #e53935; color: #fff; }
|
||||
.btn-secondary { background: #2a2a32; color: #fff; }
|
||||
label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #aaa; }
|
||||
input, select { width: 100%; padding: 10px 12px; margin-bottom: 14px; border-radius: 8px; border: 1px solid #333; background: #0f0f12; color: #fff; }
|
||||
.plans { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 16px; }
|
||||
.plan-premium { border-color: #e53935; }
|
||||
ul.features { padding-left: 18px; color: #ccc; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 8px; border-bottom: 1px solid #2a2a32; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="header">
|
||||
<div class="wrap">
|
||||
<%= link_to public_pricing_path, class: "brand" do %>Match <span>Live TV</span><% end %>
|
||||
<nav>
|
||||
<%= link_to "Prezzi", public_pricing_path %>
|
||||
<% if logged_in? %>
|
||||
<% if current_user.teams.any? %>
|
||||
· <%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first) %>
|
||||
<% end %>
|
||||
· <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||
<% else %>
|
||||
· <%= link_to "Accedi", public_login_path %>
|
||||
· <%= link_to "Registrati", public_signup_path, class: "btn btn-primary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main class="wrap">
|
||||
<% if flash[:notice] %><div class="flash notice"><%= flash[:notice] %></div><% end %>
|
||||
<% if flash[:alert] %><div class="flash alert"><%= flash[:alert] %></div><% end %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
12
backend/app/views/public/invitations/show.html.erb
Normal file
12
backend/app/views/public/invitations/show.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<% content_for :title, "Invito squadra" %>
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
||||
<p>Sei stato aggiunto allo staff come <strong><%= @invitation.staff_kind == "regia" ? "Regia" : "Trasmissione" %></strong> (<%= @invitation.email %>).</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p><%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.</p>
|
||||
<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
76
backend/app/views/public/live/index.html.erb
Normal file
76
backend/app/views/public/live/index.html.erb
Normal file
@@ -0,0 +1,76 @@
|
||||
<% content_for :title do %>Dirette in corso — Match Live TV<% end %>
|
||||
|
||||
<div class="wrap">
|
||||
<h1>Dirette in corso</h1>
|
||||
<p class="results-hint">
|
||||
Cerca per nome squadra o avversario, poi apri la diretta per guardare lo stream.
|
||||
</p>
|
||||
|
||||
<%= form_with url: public_live_index_path, method: :get, local: true, class: "search-form" do %>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="<%= @query %>"
|
||||
placeholder="Es. Tigers, Eagles, nome squadra…"
|
||||
aria-label="Cerca squadra"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">Cerca</button>
|
||||
<% if @query.present? %>
|
||||
<%= link_to "Azzera", public_live_index_path, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @query.present? %>
|
||||
<p class="results-hint">
|
||||
Risultati per «<%= h @query %>»: <%= @sessions.size %> diretta<%= @sessions.size == 1 ? "" : "e" %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if @sessions.any? %>
|
||||
<div class="live-grid">
|
||||
<% @sessions.each do |session| %>
|
||||
<% match = session.match %>
|
||||
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
|
||||
<article class="live-card">
|
||||
<h3><%= match.team.name %> vs <%= match.opponent_name %></h3>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
<%= session.platform == "matchlivetv" ? "Match Live TV" : session.platform.capitalize %>
|
||||
</p>
|
||||
<% if session.score_state %>
|
||||
<p class="card-score">
|
||||
<span class="card-sets"><%= live_score_sets_label(session.score_state) %></span>
|
||||
<% if live_score_partials_label(session.score_state).present? %>
|
||||
<span class="card-partials">Parziali: <%= live_score_partials_label(session.score_state) %></span>
|
||||
<% end %>
|
||||
<span class="card-points"><%= session.score_state.home_points %> - <%= session.score_state.away_points %></span>
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="badges">
|
||||
<% if on_air %>
|
||||
<span class="badge badge-on-air">In onda</span>
|
||||
<% elsif session.status == "live" %>
|
||||
<span class="badge badge-live">Live</span>
|
||||
<% elsif session.status == "reconnecting" %>
|
||||
<span class="badge badge-connecting">Riconnessione</span>
|
||||
<% else %>
|
||||
<span class="badge badge-wait">In avvio</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= link_to "Guarda diretta →", public_live_path(session), class: "btn-watch" %>
|
||||
</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 %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
227
backend/app/views/public/live/show.html.erb
Normal file
227
backend/app/views/public/live/show.html.erb
Normal file
@@ -0,0 +1,227 @@
|
||||
<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Match Live TV<% end %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<% unless @stream_closed %>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="wrap">
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
<h1><%= @match.team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p>
|
||||
<% if @stream_closed %>
|
||||
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
|
||||
<% elsif @on_air %>
|
||||
<span id="status-badge" class="badge badge-on-air">In onda</span>
|
||||
<% else %>
|
||||
<span id="status-badge" class="badge badge-wait">In attesa</span>
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<div id="scoreboard" class="scoreboard">
|
||||
<p id="sets-line" class="sets-line"><%= live_score_sets_label(@session.score_state) || "—" %></p>
|
||||
<p id="partials-line" class="partials-line"<%= " hidden" unless live_score_partials_label(@session.score_state).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@session.score_state) %>
|
||||
</p>
|
||||
<p id="score-line" class="score"><%= live_score_points_label(@match, @session.score_state) %></p>
|
||||
</div>
|
||||
|
||||
<% if @stream_closed %>
|
||||
<div id="stream-ended" class="stream-ended" role="status" aria-live="polite">
|
||||
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
||||
<h2>Diretta terminata</h2>
|
||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||
<% if @session.ended_at %>
|
||||
<p class="stream-ended-meta">Chiusa il <%= l(@session.ended_at.in_time_zone, format: :long) %></p>
|
||||
<% end %>
|
||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||
<%= link_to "Tutte le dirette", public_live_index_path, class: "btn btn-secondary stream-ended-btn" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<video id="player" controls playsinline muted autoplay></video>
|
||||
<p id="offline-msg" style="display:none;color:#aaa;">La diretta non è ancora disponibile. Si aggiorna automaticamente quando torna in onda.</p>
|
||||
|
||||
<script>
|
||||
const sessionId = "<%= @session.id %>";
|
||||
const hlsUrl = "<%= j @session.hls_playback_url %>";
|
||||
const video = document.getElementById("player");
|
||||
const badge = document.getElementById("status-badge");
|
||||
const setsLine = document.getElementById("sets-line");
|
||||
const partialsLine = document.getElementById("partials-line");
|
||||
const scoreLine = document.getElementById("score-line");
|
||||
const offlineMsg = document.getElementById("offline-msg");
|
||||
|
||||
let hls = null;
|
||||
let wasOnAir = <%= @on_air ? "true" : "false" %>;
|
||||
let reloadTimer = null;
|
||||
let stallPolls = 0;
|
||||
let streamClosed = false;
|
||||
|
||||
function setBadge(status, live, onAir, closed) {
|
||||
if (closed) {
|
||||
badge.textContent = "Diretta chiusa";
|
||||
badge.className = "badge badge-ended";
|
||||
return;
|
||||
}
|
||||
if (onAir) {
|
||||
badge.textContent = "In onda";
|
||||
badge.className = "badge badge-on-air";
|
||||
} else if (live) {
|
||||
badge.textContent = "LIVE";
|
||||
badge.className = "badge badge-live";
|
||||
} else if (status === "reconnecting") {
|
||||
badge.textContent = "Riconnessione";
|
||||
badge.className = "badge badge-wait";
|
||||
} else {
|
||||
badge.textContent = status;
|
||||
badge.className = "badge badge-wait";
|
||||
}
|
||||
}
|
||||
|
||||
function formatSets(score) {
|
||||
if (!score) return "—";
|
||||
return `Set ${score.current_set} · Set vinti ${score.home_sets}-${score.away_sets}`;
|
||||
}
|
||||
|
||||
function formatPartials(score) {
|
||||
const partials = score?.set_partials;
|
||||
if (!partials?.length) return "";
|
||||
return partials
|
||||
.map((p) => `Set ${p.set} ${p.home}-${p.away}`)
|
||||
.join(" · ");
|
||||
}
|
||||
|
||||
function formatPoints(data) {
|
||||
if (!data.score || !data.home_name || !data.away_name) return "—";
|
||||
const s = data.score;
|
||||
return `${data.home_name} ${s.home_points} - ${s.away_points} ${data.away_name}`;
|
||||
}
|
||||
|
||||
function updateScoreboard(data) {
|
||||
const score = data.score;
|
||||
setsLine.textContent = formatSets(score);
|
||||
const partials = formatPartials(score);
|
||||
if (partials) {
|
||||
partialsLine.textContent = `Parziali: ${partials}`;
|
||||
partialsLine.hidden = false;
|
||||
} else {
|
||||
partialsLine.hidden = true;
|
||||
}
|
||||
scoreLine.textContent = formatPoints(data);
|
||||
}
|
||||
|
||||
function showStreamEnded() {
|
||||
if (streamClosed) return;
|
||||
streamClosed = true;
|
||||
destroyPlayer();
|
||||
video.style.display = "none";
|
||||
offlineMsg.style.display = "none";
|
||||
const panel = document.createElement("div");
|
||||
panel.id = "stream-ended";
|
||||
panel.className = "stream-ended";
|
||||
panel.setAttribute("role", "status");
|
||||
panel.innerHTML = `
|
||||
<div class="stream-ended-icon" aria-hidden="true">📡</div>
|
||||
<h2>Diretta terminata</h2>
|
||||
<p>Lo streaming di questa partita è stato chiuso.</p>
|
||||
<p class="stream-ended-hint">Il punteggio finale resta visibile sopra.</p>
|
||||
<a href="<%= public_live_index_path %>" class="btn btn-secondary stream-ended-btn">Tutte le dirette</a>
|
||||
`;
|
||||
video.parentNode.insertBefore(panel, video.nextSibling);
|
||||
}
|
||||
|
||||
function hlsUrlFresh() {
|
||||
const sep = hlsUrl.includes("?") ? "&" : "?";
|
||||
return `${hlsUrl}${sep}_ts=${Date.now()}`;
|
||||
}
|
||||
|
||||
function destroyPlayer() {
|
||||
if (hls) {
|
||||
hls.destroy();
|
||||
hls = null;
|
||||
}
|
||||
video.removeAttribute("src");
|
||||
video.load();
|
||||
}
|
||||
|
||||
function startPlayer() {
|
||||
destroyPlayer();
|
||||
const url = hlsUrlFresh();
|
||||
|
||||
if (Hls.isSupported()) {
|
||||
hls = new Hls({ lowLatencyMode: true, enableWorker: true });
|
||||
hls.loadSource(url);
|
||||
hls.attachMedia(video);
|
||||
hls.on(Hls.Events.MANIFEST_PARSED, () => video.play().catch(() => {}));
|
||||
hls.on(Hls.Events.ERROR, (_, data) => {
|
||||
if (data.fatal) {
|
||||
scheduleReload(2500);
|
||||
}
|
||||
});
|
||||
} else if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = url;
|
||||
video.addEventListener("loadedmetadata", () => video.play().catch(() => {}), { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReload(delayMs) {
|
||||
if (reloadTimer) return;
|
||||
reloadTimer = setTimeout(() => {
|
||||
reloadTimer = null;
|
||||
pollStatus();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function pollStatus() {
|
||||
try {
|
||||
const res = await fetch(`/live/${sessionId}/status.json`);
|
||||
const data = await res.json();
|
||||
setBadge(data.status, data.live, data.on_air, data.stream_closed);
|
||||
updateScoreboard(data);
|
||||
|
||||
if (data.stream_closed) {
|
||||
showStreamEnded();
|
||||
return;
|
||||
}
|
||||
|
||||
const onAir = !!data.on_air;
|
||||
|
||||
if (onAir) {
|
||||
offlineMsg.style.display = "none";
|
||||
if (!wasOnAir) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
} else if (video.readyState < 2) {
|
||||
stallPolls += 1;
|
||||
if (stallPolls >= 2) {
|
||||
startPlayer();
|
||||
stallPolls = 0;
|
||||
}
|
||||
} else {
|
||||
stallPolls = 0;
|
||||
}
|
||||
} else {
|
||||
offlineMsg.style.display = "block";
|
||||
destroyPlayer();
|
||||
stallPolls = 0;
|
||||
}
|
||||
|
||||
wasOnAir = onAir;
|
||||
} catch (_) {
|
||||
scheduleReload(5000);
|
||||
}
|
||||
}
|
||||
|
||||
if (wasOnAir) {
|
||||
startPlayer();
|
||||
} else {
|
||||
offlineMsg.style.display = "block";
|
||||
}
|
||||
|
||||
pollStatus();
|
||||
setInterval(pollStatus, 3000);
|
||||
</script>
|
||||
<% end %>
|
||||
</div>
|
||||
63
backend/app/views/public/pages/features.html.erb
Normal file
63
backend/app/views/public/pages/features.html.erb
Normal file
@@ -0,0 +1,63 @@
|
||||
<% 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." %>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Revoca accessi</h3>
|
||||
<p>L'owner rimuove membri dello staff e annulla richieste pendenti dalla dashboard web.</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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<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>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Regia separata</h3>
|
||||
<p>Secondo dispositivo per aggiornare il punteggio via QR pairing.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>Pagina pubblica /live</h3>
|
||||
<p>Genitori e tifosi guardano dal browser senza installare nulla.</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>
|
||||
</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>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3>YouTube della società</h3>
|
||||
<p>Premium Full: relay verso il canale YouTube 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>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p style="text-align:center;margin:40px 0">
|
||||
<%= link_to "Registra la squadra", public_signup_path, class: "btn btn-primary" %>
|
||||
</p>
|
||||
</div>
|
||||
88
backend/app/views/public/pages/home.html.erb
Normal file
88
backend/app/views/public/pages/home.html.erb
Normal file
@@ -0,0 +1,88 @@
|
||||
<% 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." %>
|
||||
|
||||
<section class="hero hero-split">
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-content">
|
||||
<p class="hero-live-badge"><span class="hero-live-dot" aria-hidden="true"></span> LIVE</p>
|
||||
<h1>Ogni partita, ogni evento, per chi <span class="hero-accent">non può esserci.</span></h1>
|
||||
<p class="tagline">
|
||||
Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici —
|
||||
guardano dal browser, senza installare nulla.
|
||||
E se te la sei persa, la ritrovi nell'archivio.
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<%= link_to "Registra la tua squadra", public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to "Guarda le dirette", public_live_index_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<ul class="hero-features" aria-label="Vantaggi principali">
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<span class="hero-feature-label">Dirette stabili</span>
|
||||
</li>
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-cloud"></i></span>
|
||||
<span class="hero-feature-label">Archivio sicuro</span>
|
||||
</li>
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-user-group"></i></span>
|
||||
<span class="hero-feature-label">Condividi con chi vuoi</span>
|
||||
</li>
|
||||
<li class="hero-feature-item hero-feature-item--last">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||
<span class="hero-feature-label">Tutto dal tuo telefono</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<%= image_tag "/hero-devices.png", alt: "Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra", class: "hero-devices-img", loading: "eager", fetchpriority: "high" %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap">
|
||||
<h2>Come funziona</h2>
|
||||
<div class="steps">
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-registra-squadra.png?v=1", alt: "Registrazione squadra: scudo, pallone e modulo con email, password e nome squadra", class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">01</div>
|
||||
<h3>Registra la squadra</h3>
|
||||
<p>La società si iscrive sul sito e sceglie il piano più adatto.</p>
|
||||
</div>
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-invita-trasmette.png?v=1", alt: "Invito a trasmettere: smartphone con invio email e busta con accetta invito", class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">02</div>
|
||||
<h3>Invita chi trasmette</h3>
|
||||
<p>Coach e volontari ricevono un invito: ognuno accede con la propria email.</p>
|
||||
</div>
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-vai-in-diretta.png?v=1", alt: "Vai in diretta: smartphone su treppiede, punteggio live e condivisione link", class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">03</div>
|
||||
<h3>Vai in diretta</h3>
|
||||
<p>Dal telefono si avvia la partita: punteggio aggiornato e link da condividere con le famiglie.</p>
|
||||
</div>
|
||||
<div class="step step--visual">
|
||||
<div class="step-visual">
|
||||
<%= image_tag "/home-step-tutti-guardano.png?v=2", alt: "Tutti guardano da casa: archivio partite, partita salvata e link per genitori e parenti", class: "step-visual-img", loading: "lazy" %>
|
||||
</div>
|
||||
<div class="step-num">04</div>
|
||||
<h3>Tutti guardano da casa</h3>
|
||||
<p>Genitori, nonni e parenti aprono il link nel browser. La partita resta anche in archivio.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap plans-teaser">
|
||||
<h2>Piani per ogni esigenza</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>
|
||||
72
backend/app/views/public/pages/pricing.html.erb
Normal file
72
backend/app/views/public/pages/pricing.html.erb
Normal file
@@ -0,0 +1,72 @@
|
||||
<% content_for :title, "Prezzi — Match Live TV" %>
|
||||
|
||||
<div class="wrap" style="padding-top:24px">
|
||||
<h1>Piani per la tua squadra</h1>
|
||||
<p style="color:#aaa">Abbonamento annuale per squadra: lo staff gestisce trasmissione e regia con credenziali dedicate. Pagamento sicuro su Stripe.</p>
|
||||
|
||||
<div class="plans">
|
||||
<div class="plan-card">
|
||||
<h3>Free</h3>
|
||||
<div class="plan-price">€0</div>
|
||||
<ul>
|
||||
<li><strong>1</strong> persona staff / anno per la <strong>trasmissione</strong></li>
|
||||
<li><strong>1</strong> persona staff / anno per la <strong>regia</strong></li>
|
||||
<li><strong>1</strong> live alla volta su matchlivetv.eminux.it</li>
|
||||
<li>Punteggio live e regia da app</li>
|
||||
<li style="color:#888">No salvataggio / replay</li>
|
||||
<li style="color:#888">No YouTube</li>
|
||||
</ul>
|
||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<div class="plan-card featured">
|
||||
<h3>Premium Light</h3>
|
||||
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
|
||||
<ul>
|
||||
<li><strong>5</strong> persone staff / anno per la <strong>trasmissione</strong></li>
|
||||
<li><strong>5</strong> persone staff / anno per la <strong>regia</strong></li>
|
||||
<li>Fino a <strong>3</strong> partite in contemporanea</li>
|
||||
<li>Live su matchlivetv.eminux.it e sul <strong>canale YouTube Match TV Light</strong></li>
|
||||
<li>Archivio server <strong>30 giorni</strong> e download su telefono</li>
|
||||
</ul>
|
||||
<%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
||||
<div class="plan-card">
|
||||
<h3>Premium Full</h3>
|
||||
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
|
||||
<ul>
|
||||
<li>Staff <strong>illimitato</strong> / anno (trasmissione e regia)</li>
|
||||
<li>Fino a <strong>10</strong> partite in contemporanea</li>
|
||||
<li>Collegamento con il <strong>canale YouTube della società</strong></li>
|
||||
<li>Archivio server <strong>90 giorni</strong> e download su telefono</li>
|
||||
<li>Sito società (in arrivo)</li>
|
||||
</ul>
|
||||
<%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Staff trasmissione / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
|
||||
<tr><td>Staff regia / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
|
||||
<tr><td>Partite in contemporanea</td><td>1</td><td>3</td><td>10</td></tr>
|
||||
<tr><td>Live su Match Live TV</td><td>Sì</td><td>Sì</td><td>Sì</td></tr>
|
||||
<tr><td>YouTube</td><td>No</td><td>Match TV Light</td><td>Canale società</td></tr>
|
||||
<tr><td>Replay server</td><td>No</td><td>30 gg</td><td>90 gg</td></tr>
|
||||
<tr><td>Download telefono</td><td>No</td><td>Sì</td><td>Sì</td></tr>
|
||||
<tr><td>Prezzo</td><td>€0</td><td>€40/anno o €5/mese</td><td>€200/anno o €20/mese</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0">Esigenze diverse?</h3>
|
||||
<p style="color:#aaa;margin-bottom:16px">Per società con più squadre, tornei o integrazioni custom, contattaci: troviamo insieme l'offerta migliore.</p>
|
||||
<%= mail_to "info@matchlivetv.eminux.it", "Contattaci", class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
9
backend/app/views/public/pages/privacy.html.erb
Normal file
9
backend/app/views/public/pages/privacy.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<% 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>
|
||||
</div>
|
||||
9
backend/app/views/public/pages/terms.html.erb
Normal file
9
backend/app/views/public/pages/terms.html.erb
Normal file
@@ -0,0 +1,9 @@
|
||||
<% 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>
|
||||
</div>
|
||||
20
backend/app/views/public/registrations/new.html.erb
Normal file
20
backend/app/views/public/registrations/new.html.erb
Normal file
@@ -0,0 +1,20 @@
|
||||
<% content_for :title, "Registrati — Match Live TV" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>Registra la squadra</h1>
|
||||
<p class="auth-lead">Crea l'account del referente (coach o dirigente), poi configurerai la squadra.</p>
|
||||
<div class="card">
|
||||
<%= form_with model: @user, url: public_signup_path do |f| %>
|
||||
<%= f.label :name, "Nome" %>
|
||||
<%= f.text_field :name, required: true %>
|
||||
<%= f.label :email, "Email" %>
|
||||
<%= f.email_field :email, required: true %>
|
||||
<%= f.label :password, "Password" %>
|
||||
<%= f.password_field :password, required: true, minlength: 8 %>
|
||||
<%= f.label :password_confirmation, "Conferma password" %>
|
||||
<%= f.password_field :password_confirmation, required: true %>
|
||||
<%= f.submit "Continua", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer">Hai già un account? <%= link_to "Accedi", public_login_path %></p>
|
||||
</div>
|
||||
</section>
|
||||
37
backend/app/views/public/replay/show.html.erb
Normal file
37
backend/app/views/public/replay/show.html.erb
Normal file
@@ -0,0 +1,37 @@
|
||||
<% content_for :title, "Replay — #{@match.team.name} vs #{@match.opponent_name}" %>
|
||||
|
||||
<div class="wrap">
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
<h1>Replay</h1>
|
||||
<p style="color:#aaa"><%= @match.team.name %> vs <%= @match.opponent_name %></p>
|
||||
|
||||
<% if @recording %>
|
||||
<div class="card" style="margin:16px 0">
|
||||
<% if @session.hls_playback_url.present? %>
|
||||
<video id="replay-player" class="replay-video" controls playsinline></video>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
|
||||
<script>
|
||||
(function() {
|
||||
var src = "<%= j @session.hls_playback_url %>";
|
||||
var video = document.getElementById("replay-player");
|
||||
if (video.canPlayType("application/vnd.apple.mpegurl")) {
|
||||
video.src = src;
|
||||
} else if (typeof Hls !== "undefined" && Hls.isSupported()) {
|
||||
var hls = new Hls();
|
||||
hls.loadSource(src);
|
||||
hls.attachMedia(video);
|
||||
} else {
|
||||
video.outerHTML = "<p>Il replay non è disponibile su questo browser.</p>";
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<% else %>
|
||||
<p>Registrazione indicizzata. Il playback dipende dalla retention del piano Premium.</p>
|
||||
<% end %>
|
||||
<% if @recording.expires_at %>
|
||||
<p class="stream-ended-meta" style="margin-top:12px">Disponibile fino al <%= l @recording.expires_at, format: :long %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
15
backend/app/views/public/sessions/new.html.erb
Normal file
15
backend/app/views/public/sessions/new.html.erb
Normal file
@@ -0,0 +1,15 @@
|
||||
<% content_for :title, "Accedi — Match Live TV" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>Accedi</h1>
|
||||
<div class="card">
|
||||
<%= form_with url: public_login_path do |f| %>
|
||||
<%= label_tag :email, "Email" %>
|
||||
<%= email_field_tag :email, params[:email], required: true %>
|
||||
<%= label_tag :password, "Password" %>
|
||||
<%= password_field_tag :password, required: true %>
|
||||
<%= submit_tag "Entra", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer">Nuovo? <%= link_to "Registrati", public_signup_path %></p>
|
||||
</div>
|
||||
</section>
|
||||
49
backend/app/views/public/teams/billing.html.erb
Normal file
49
backend/app/views/public/teams/billing.html.erb
Normal file
@@ -0,0 +1,49 @@
|
||||
<% content_for :title, "Abbonamento — #{@team.name}" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Abbonamento</h1>
|
||||
<p>Squadra: <strong><%= @team.name %></strong> · Piano attuale: <strong><%= @entitlements.plan.name %></strong></p>
|
||||
|
||||
<div class="plans">
|
||||
<% @plans.each do |plan| %>
|
||||
<div class="plan-card <%= 'featured' if plan.slug == @entitlements.plan.slug %>">
|
||||
<h3><%= plan.name %></h3>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
<div class="plan-price" style="color:#e53935">€40/anno</div>
|
||||
<p style="color:#888;font-size:0.85rem">oppure €5/mese</p>
|
||||
<% else %>
|
||||
<div class="plan-price">€200/anno</div>
|
||||
<p style="color:#888;font-size:0.85rem">oppure €20/mese</p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<li>Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno</li>
|
||||
<li>Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno</li>
|
||||
<li>Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea</li>
|
||||
<li>Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %></li>
|
||||
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||
</ul>
|
||||
<% if plan.slug == @entitlements.plan.slug %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||
<%= link_to "Attiva #{plan.name}", public_team_checkout_path(@team, plan: plan.slug), class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p style="color:#888">Stripe non configurato</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:24px;text-align:center">
|
||||
<p style="color:#aaa">Esigenze diverse? <%= mail_to "info@matchlivetv.eminux.it", "Contattaci" %> per un'offerta su misura.</p>
|
||||
</div>
|
||||
|
||||
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
||||
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_team_portal_path(@team), class: "btn btn-secondary" %></p>
|
||||
<% end %>
|
||||
|
||||
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>
|
||||
</div>
|
||||
82
backend/app/views/public/teams/dashboard.html.erb
Normal file
82
backend/app/views/public/teams/dashboard.html.erb
Normal file
@@ -0,0 +1,82 @@
|
||||
<% content_for :title, "#{@team.name} — Dashboard" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1><%= @team.name %></h1>
|
||||
<p>
|
||||
Piano: <strong><%= @entitlements.plan.name %></strong>
|
||||
· Staff trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||
· Staff regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
||||
· Partite attive: <strong><%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %></strong>
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:20px 0">
|
||||
<%= link_to "Abbonamento", public_team_billing_path(@team), class: "btn btn-primary" %>
|
||||
<%= link_to "Aggiungi staff", public_team_invite_path(@team), class: "btn btn-secondary" %>
|
||||
<%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<h2>Staff attivo</h2>
|
||||
<div class="card">
|
||||
<% if @members.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Nome</th><th>Email</th><th>Ruolo</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @members.each do |ut| %>
|
||||
<tr>
|
||||
<td><%= ut.user.name %></td>
|
||||
<td><%= ut.user.email %></td>
|
||||
<td><%= ut.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
|
||||
<td>
|
||||
<%= button_to "Revoca", public_team_remove_member_path(@team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Revocare l'accesso a #{ut.user.name}?" } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessun membro staff. <%= link_to "Aggiungi qualcuno", public_team_invite_path(@team) %>.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<h2>Staff in attesa</h2>
|
||||
<div class="card">
|
||||
<% if @pending_invitations.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Email</th><th>Ruolo</th><th>Scade</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @pending_invitations.each do |inv| %>
|
||||
<tr>
|
||||
<td><%= inv.email %></td>
|
||||
<td><%= inv.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
|
||||
<td><%= l inv.expires_at, format: :short %></td>
|
||||
<td>
|
||||
<%= button_to "Annulla", public_team_destroy_invitation_path(@team, inv), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessuna richiesta in sospeso.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entitlements.can_access_recordings? && @recordings.any? %>
|
||||
<h2>Archivio gare</h2>
|
||||
<div class="card">
|
||||
<table class="data">
|
||||
<thead><tr><th>Partita</th><th>Replay</th></tr></thead>
|
||||
<tbody>
|
||||
<% @recordings.each do |rec| %>
|
||||
<tr>
|
||||
<td><%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %></td>
|
||||
<td><%= link_to "Guarda", rec.replay_url %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p style="color:#888;margin-top:24px">Usa l'app mobile con le stesse credenziali per avviare partite e dirette.</p>
|
||||
</div>
|
||||
28
backend/app/views/public/teams/invite.html.erb
Normal file
28
backend/app/views/public/teams/invite.html.erb
Normal file
@@ -0,0 +1,28 @@
|
||||
<% content_for :title, "Aggiungi staff — #{@team.name}" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Aggiungi staff</h1>
|
||||
<p>
|
||||
Trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||
· Regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
||||
</p>
|
||||
|
||||
<div class="card" style="max-width:520px">
|
||||
<%= form_with url: public_team_invite_path(@team), method: :post do %>
|
||||
<%= label_tag :staff_kind, "Ruolo" %>
|
||||
<%= select_tag :staff_kind, options_for_select([
|
||||
["Trasmissione (camera / streaming)", "transmission"],
|
||||
["Regia (punteggio)", "regia"]
|
||||
], params[:staff_kind] || "transmission") %>
|
||||
<%= label_tag :email, "Email del membro staff" %>
|
||||
<%= email_field_tag :email, params[:email], required: true %>
|
||||
<%= submit_tag "Genera link di accesso", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<% if defined?(@invite_url) && @invite_url.present? %>
|
||||
<p style="margin-top:16px">Condividi (valido 7 giorni):</p>
|
||||
<code style="word-break:break-all;display:block;background:#0a0a0e;padding:12px;border-radius:8px"><%= @invite_url %></code>
|
||||
<p style="color:#888;font-size:0.88rem;margin-top:8px">Il membro dello staff si registra con la stessa email e accetta l'accesso.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>
|
||||
</div>
|
||||
21
backend/app/views/public/teams/new.html.erb
Normal file
21
backend/app/views/public/teams/new.html.erb
Normal file
@@ -0,0 +1,21 @@
|
||||
<% content_for :title, "Crea squadra — Match Live TV" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1>La tua squadra</h1>
|
||||
<p class="auth-lead">Ultimo passo: nome, sport e piano iniziale.</p>
|
||||
<div class="card card-wide">
|
||||
<%= form_with url: public_teams_path do %>
|
||||
<%= label_tag "team[name]", "Nome squadra" %>
|
||||
<%= text_field_tag "team[name]", nil, required: true %>
|
||||
<%= label_tag "team[sport]", "Sport" %>
|
||||
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], "volleyball") %>
|
||||
<%= label_tag :plan, "Piano iniziale" %>
|
||||
<%= select_tag :plan, options_for_select([
|
||||
["Free — 1 staff trasmissione + 1 regia, 1 live", "free"],
|
||||
["Premium Light — €40/anno o €5/mese", "premium_light"],
|
||||
["Premium Full — €200/anno o €20/mese", "premium_full"]
|
||||
], "free") %>
|
||||
<%= submit_tag "Crea squadra", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
13
backend/app/views/shared/_marketing_footer.html.erb
Normal file
13
backend/app/views/shared/_marketing_footer.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<strong style="color:#fff">Match Live TV</strong> — Ogni partita, ogni evento, per chi non può esserci
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to "Prezzi", public_prezzi_path %> ·
|
||||
<%= link_to "Dirette", public_live_index_path %> ·
|
||||
<%= link_to "Privacy", public_privacy_path %> ·
|
||||
<%= link_to "Termini", public_termini_path %>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
76
backend/app/views/shared/_marketing_nav.html.erb
Normal file
76
backend/app/views/shared/_marketing_nav.html.erb
Normal file
@@ -0,0 +1,76 @@
|
||||
<% live_section = request.path.start_with?("/live") %>
|
||||
<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 %>
|
||||
|
||||
<button type="button" class="nav-toggle" aria-label="Apri menu" aria-expanded="false" aria-controls="site-nav">
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="Menu principale" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<%= link_to "Home", root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to "Funzionalità", public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to "Prezzi", public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
<%= link_to "Dirette live", public_live_index_path, class: (live_section ? "nav-active" : nil) %>
|
||||
<div class="nav-actions">
|
||||
<% if logged_in? %>
|
||||
<% if current_user.teams.any? %>
|
||||
<%= link_to "Dashboard", public_team_dashboard_path(current_user.teams.first), class: "nav-link-item" %>
|
||||
<% end %>
|
||||
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||
<% else %>
|
||||
<%= link_to "Accedi", public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to "Registra squadra", public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var chrome = document.querySelector(".site-chrome");
|
||||
var toggle = document.querySelector(".nav-toggle");
|
||||
var backdrop = document.getElementById("nav-backdrop");
|
||||
var nav = document.getElementById("site-nav");
|
||||
if (!chrome || !toggle || !nav) return;
|
||||
|
||||
function setOpen(open) {
|
||||
chrome.classList.toggle("nav-open", open);
|
||||
document.body.classList.toggle("nav-menu-open", open);
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
toggle.setAttribute("aria-label", open ? "Chiudi menu" : "Apri menu");
|
||||
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
}
|
||||
|
||||
function closeMenu() { setOpen(false); }
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
||||
});
|
||||
|
||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||
el.addEventListener("click", closeMenu);
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
7
backend/app/views/shared/_meta_tags.html.erb
Normal file
7
backend/app/views/shared/_meta_tags.html.erb
Normal file
@@ -0,0 +1,7 @@
|
||||
<% 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." %>
|
||||
<meta name="description" content="<%= description %>">
|
||||
<meta property="og:title" content="<%= content_for?(:title) ? yield(:title) : 'Match Live TV' %>">
|
||||
<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">
|
||||
Reference in New Issue
Block a user