Aggiunge annunci in-app/sito, contatti e migliora UI copertina sul portale.

Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 15:30:49 +02:00
co-authored by Cursor
parent e502844b88
commit 5b723bf844
81 changed files with 2177 additions and 38 deletions
@@ -0,0 +1,69 @@
module Admin
class AnnouncementsController < Admin::BaseController
before_action :set_announcement, only: %i[edit update destroy]
def index
@announcements = AppAnnouncement.newest
end
def new
@announcement = AppAnnouncement.new(
kind: "info",
severity: "info",
status: "draft",
show_on_app: true,
show_on_web_public: true,
show_on_web_private: true,
dismissible: true
)
end
def create
@announcement = AppAnnouncement.new(announcement_params)
@announcement.created_by_admin = current_admin_account
if @announcement.save
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :new, status: :unprocessable_entity
end
end
def edit; end
def update
if @announcement.update(announcement_params)
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
else
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity
end
end
def destroy
@announcement.destroy!
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
end
private
def set_announcement
@announcement = AppAnnouncement.find(params[:id])
end
def announcement_params
params.require(:app_announcement).permit(
:kind, :severity, :status, :title, :body,
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
:show_on_app, :show_on_web_public, :show_on_web_private
).tap do |permitted|
%i[dismissible show_on_app show_on_web_public show_on_web_private].each do |key|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
end
%i[starts_at ends_at action_url action_label].each do |key|
permitted[key] = nil if permitted[key].blank?
end
end
end
end
end
@@ -0,0 +1,13 @@
module Api
module V1
class AnnouncementsController < BaseController
skip_before_action :authenticate_request!
def index
platform = params[:platform].to_s.presence
announcements = AppAnnouncement.for_app.newest
render json: announcements.map { |item| item.as_api_json(platform: platform) }
end
end
end
end
@@ -0,0 +1,46 @@
module Public
class ContactsController < WebBaseController
def show
@inquiry = empty_inquiry
end
def create
result = Contacts::SubmitInquiry.call(params: inquiry_params, ip: request.remote_ip)
if result.ok?
redirect_to public_contatti_path, notice: t("flash.contacts.sent")
return
end
@inquiry = inquiry_params
flash.now[:alert] = t("flash.contacts.#{result.error}")
render :show, status: result.error == :throttled ? :too_many_requests : :unprocessable_entity
end
private
def inquiry_params
{
name: params[:name].to_s,
email: params[:email].to_s,
club_name: params[:club_name].to_s,
topic: params[:topic].to_s,
message: params[:message].to_s,
website: params[:website].to_s,
accept_privacy: params[:accept_privacy].to_s
}
end
def empty_inquiry
{
name: "",
email: "",
club_name: "",
topic: "commercial",
message: "",
website: "",
accept_privacy: ""
}
end
end
end
@@ -9,6 +9,7 @@ module Public
protect_from_forgery with: :exception
before_action :set_site_locale
before_action :load_site_announcements
private
@@ -40,5 +41,27 @@ module Public
def logged_in?
current_user.present?
end
PRIVATE_WEB_CONTROLLERS = %w[
accounts clubs teams club_recordings club_billing
club_matches matches team_roster_members
].freeze
def load_site_announcements
@site_announcements = if skip_site_announcements?
AppAnnouncement.none
else
channel = private_web_area? ? :web_private : :web_public
AppAnnouncement.for_channel(channel).newest
end
end
def skip_site_announcements?
controller_name == "regia"
end
def private_web_area?
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
end
end
end
@@ -8,6 +8,7 @@ module Public
{ 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}/contatti", changefreq: "monthly", priority: "0.6" },
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
+27
View File
@@ -37,4 +37,31 @@ module AdminHelper
rescue ArgumentError, TypeError
nil
end
def announcement_status_badge(item)
case item.status
when "published" then item.severity == "critical" ? "live" : "ready"
when "archived" then "paused"
else "connecting"
end
end
def announcement_window_label(item)
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
if start_label && end_label
"#{start_label}#{end_label}"
elsif start_label
I18n.t("admin.announcements.window.from", time: start_label)
elsif end_label
I18n.t("admin.announcements.window.until", time: end_label)
else
I18n.t("admin.announcements.window.always")
end
end
def announcement_channels_label(item)
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
end
end
+1 -1
View File
@@ -1,6 +1,6 @@
module LegalHelper
def legal_last_updated
"3 giugno 2026"
"20 agosto 2026"
end
def cookie_policy_last_updated
+25
View File
@@ -0,0 +1,25 @@
class ContactMailer < ApplicationMailer
def inquiry(name:, email:, club_name:, topic:, message:)
@name = name
@email = email
@club_name = club_name.presence
@topic = topic
@message = message
mail(
to: recipient_for(topic),
reply_to: email,
subject: t("mailers.contact.subject.#{topic}", name: name)
)
end
private
def recipient_for(topic)
if topic == "privacy"
MatchLiveTv.privacy_controller_email
else
MatchLiveTv.support_email
end
end
end
+1
View File
@@ -2,6 +2,7 @@ class AdminAccount < ApplicationRecord
include PasswordComplexity
has_secure_password
has_many :app_announcements, foreign_key: :created_by_admin_id, dependent: :nullify, inverse_of: :created_by_admin
validates :username, presence: true, uniqueness: true
end
+93
View File
@@ -0,0 +1,93 @@
class AppAnnouncement < ApplicationRecord
KINDS = %w[info maintenance update].freeze
SEVERITIES = %w[info warning critical].freeze
STATUSES = %w[draft published archived].freeze
CHANNELS = {
app: :show_on_app,
web_public: :show_on_web_public,
web_private: :show_on_web_private
}.freeze
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
validates :kind, :severity, :status, :title, :body, presence: true
validates :kind, inclusion: { in: KINDS }
validates :severity, inclusion: { in: SEVERITIES }
validates :status, inclusion: { in: STATUSES }
validates :title, length: { maximum: 120 }
validates :body, length: { maximum: 2000 }
validates :action_label, length: { maximum: 40 }, allow_blank: true
validates :action_url, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
validate :ends_at_after_starts_at
validate :at_least_one_channel
scope :newest, -> { order(created_at: :desc) }
scope :published, -> { where(status: "published") }
scope :active_now, lambda {
now = Time.current
published
.where("starts_at IS NULL OR starts_at <= ?", now)
.where("ends_at IS NULL OR ends_at > ?", now)
}
def self.for_channel(channel)
column = CHANNELS[channel.to_sym]
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
active_now.where(column => true)
end
def self.for_app
for_channel(:app)
end
def published?
status == "published"
end
def selected_channels
CHANNELS.each_with_object([]) do |(key, column), keys|
keys << key if public_send(column)
end
end
def as_api_json(platform: nil)
{
id: id,
kind: kind,
severity: severity,
title: title,
body: body,
dismissible: dismissible,
action_url: resolved_action_url(platform),
action_label: action_label.presence,
starts_at: starts_at&.iso8601,
ends_at: ends_at&.iso8601
}
end
def resolved_action_url(platform)
return action_url if action_url.present?
return nil unless kind == "update"
case platform.to_s
when "android" then MatchLiveTv.play_store_url
when "ios" then MatchLiveTv.app_store_url
end
end
private
def at_least_one_channel
return if show_on_app? || show_on_web_public? || show_on_web_private?
errors.add(:base, :no_channel)
end
def ends_at_after_starts_at
return if starts_at.blank? || ends_at.blank?
return if ends_at > starts_at
errors.add(:ends_at, :invalid)
end
end
@@ -0,0 +1,102 @@
module Contacts
class SubmitInquiry
TOPICS = %w[commercial support privacy].freeze
LIMIT = 5
WINDOW = 1.hour
NAME_MAX = 120
CLUB_MAX = 160
MESSAGE_MIN = 10
MESSAGE_MAX = 4_000
Result = Struct.new(:ok, :error, keyword_init: true)
def self.call(params:, ip:)
new(params: params, ip: ip).call
end
def initialize(params:, ip:)
@params = params
@ip = ip.to_s.presence || "unknown"
end
def call
if honeypot_filled?
record_attempt
return Result.new(ok: true, error: nil)
end
return Result.new(ok: false, error: :throttled) if throttled?
error = validate
return Result.new(ok: false, error: error) if error
record_attempt
deliver
Result.new(ok: true, error: nil)
end
private
def honeypot_filled?
@params[:website].to_s.strip.present?
end
def throttled?
current_count >= LIMIT
end
def validate
return :privacy_required unless @params[:accept_privacy].to_s == "1"
return :invalid if name.blank? || name.length > NAME_MAX
return :invalid_email if email.blank? || email !~ URI::MailTo::EMAIL_REGEXP
return :invalid if club_name.length > CLUB_MAX
return :invalid unless TOPICS.include?(topic)
return :invalid_message if message.length < MESSAGE_MIN || message.length > MESSAGE_MAX
nil
end
def deliver
mail = ContactMailer.inquiry(
name: name,
email: email,
club_name: club_name,
topic: topic,
message: message
)
MatchLiveTv.deliver_mail(mail)
end
def record_attempt
Rails.cache.write(cache_key, current_count + 1, expires_in: WINDOW, raw: true)
end
def current_count
Rails.cache.read(cache_key, raw: true).to_i
end
def cache_key
"contacts:inquiry:#{@ip}"
end
def name
@params[:name].to_s.strip
end
def email
@params[:email].to_s.strip.downcase
end
def club_name
@params[:club_name].to_s.strip
end
def topic
@params[:topic].to_s.strip
end
def message
@params[:message].to_s.strip
end
end
end
@@ -0,0 +1,79 @@
<%= form_with model: announcement, url: (announcement.persisted? ? admin_announcement_path(announcement) : admin_announcements_path),
method: (announcement.persisted? ? :patch : :post), local: true, class: "admin-form" do |f| %>
<div class="admin-form-row">
<div>
<%= f.label :kind, t("admin.announcements.form.kind") %>
<%= f.select :kind, AppAnnouncement::KINDS.map { |k| [t("admin.announcements.kind.#{k}"), k] } %>
</div>
<div>
<%= f.label :severity, t("admin.announcements.form.severity") %>
<%= f.select :severity, AppAnnouncement::SEVERITIES.map { |k| [t("admin.announcements.severity.#{k}"), k] } %>
</div>
</div>
<div>
<%= f.label :status, t("admin.announcements.form.status") %>
<%= f.select :status, AppAnnouncement::STATUSES.map { |k| [t("admin.announcements.status.#{k}"), k] } %>
</div>
<fieldset class="admin-channels">
<legend><%= t("admin.announcements.form.channels") %></legend>
<label class="admin-checkbox">
<%= f.check_box :show_on_app %>
<span><%= t("admin.announcements.channels.app") %></span>
</label>
<label class="admin-checkbox">
<%= f.check_box :show_on_web_public %>
<span><%= t("admin.announcements.channels.web_public") %></span>
</label>
<label class="admin-checkbox">
<%= f.check_box :show_on_web_private %>
<span><%= t("admin.announcements.channels.web_private") %></span>
</label>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
</fieldset>
<div>
<%= f.label :title, t("admin.announcements.form.title") %>
<%= f.text_field :title, required: true, maxlength: 120 %>
</div>
<div>
<%= f.label :body, t("admin.announcements.form.body") %>
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000 %>
</div>
<div class="admin-form-row">
<div>
<%= f.label :starts_at, t("admin.announcements.form.starts_at") %>
<%= f.datetime_field :starts_at, value: announcement.starts_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
</div>
<div>
<%= f.label :ends_at, t("admin.announcements.form.ends_at") %>
<%= f.datetime_field :ends_at, value: announcement.ends_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
</div>
</div>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
<div>
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
<%= f.url_field :action_url, placeholder: "https://" %>
</div>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
<div>
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
<%= f.text_field :action_label, maxlength: 40 %>
</div>
<label class="admin-checkbox">
<%= f.check_box :dismissible %>
<span><%= t("admin.announcements.form.dismissible") %></span>
</label>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.dismissible_hint") %></p>
<div class="admin-form-actions">
<%= f.submit (announcement.persisted? ? t("admin.announcements.form.submit_update") : t("admin.announcements.form.submit_create")), class: "admin-btn admin-btn--primary" %>
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
</div>
<% end %>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.announcements.edit.title") %></h2>
</div>
<div class="panel">
<%= render "form", announcement: @announcement %>
</div>
@@ -0,0 +1,56 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.announcements.index.title") %></h2>
<p class="muted admin-page-sub"><%= t("admin.announcements.index.lead") %></p>
</div>
<div class="panel" style="margin-bottom:1.5rem">
<div class="admin-toolbar">
<%= link_to t("admin.announcements.index.new"), new_admin_announcement_path, class: "admin-btn admin-btn--primary" %>
</div>
</div>
<div class="panel">
<h2><%= t("admin.announcements.index.table_title") %></h2>
<% if @announcements.any? %>
<div class="admin-table-wrap">
<table class="admin-table">
<thead>
<tr>
<th><%= t("admin.announcements.table.status") %></th>
<th><%= t("admin.announcements.table.kind") %></th>
<th><%= t("admin.announcements.table.title") %></th>
<th><%= t("admin.announcements.table.channels") %></th>
<th><%= t("admin.announcements.table.window") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @announcements.each do |item| %>
<tr>
<td>
<span class="badge badge--<%= announcement_status_badge(item) %>"><%= t("admin.announcements.status.#{item.status}") %></span>
</td>
<td class="muted"><%= t("admin.announcements.kind.#{item.kind}") %></td>
<td>
<strong><%= item.title %></strong>
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(item.body, length: 90) %></div>
</td>
<td class="muted"><%= announcement_channels_label(item) %></td>
<td class="muted"><%= announcement_window_label(item) %></td>
<td class="admin-actions">
<%= link_to t("admin.announcements.actions.edit"), edit_admin_announcement_path(item), class: "admin-btn admin-btn--sm" %>
<%= button_to t("admin.announcements.actions.delete"), admin_announcement_path(item), method: :delete,
class: "admin-btn admin-btn--sm admin-btn--danger",
form: { data: { confirm: t("admin.announcements.actions.delete_confirm") } } %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% else %>
<p class="empty"><%= t("admin.announcements.index.none") %></p>
<% end %>
</div>
@@ -0,0 +1,9 @@
<% content_for :body_class, "admin-body" %>
<div class="admin-page-head">
<h2 class="admin-page-title"><%= t("admin.announcements.new.title") %></h2>
</div>
<div class="panel">
<%= render "form", announcement: @announcement %>
</div>
@@ -0,0 +1,13 @@
<p><strong><%= t("mailers.contact.heading") %></strong></p>
<p>
<%= t("mailers.contact.name") %> <%= @name %><br>
<%= t("mailers.contact.email") %> <%= @email %><br>
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %><br>
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
</p>
<p><%= t("mailers.contact.message") %></p>
<p style="white-space:pre-wrap"><%= @message %></p>
<p style="color:#666;font-size:12px"><%= t("mailers.contact.footer") %></p>
@@ -0,0 +1,11 @@
<%= t("mailers.contact.heading") %>
<%= t("mailers.contact.name") %> <%= @name %>
<%= t("mailers.contact.email") %> <%= @email %>
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %>
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
<%= t("mailers.contact.message") %>
<%= @message %>
<%= t("mailers.contact.footer") %>
+2 -1
View File
@@ -4,7 +4,7 @@
<title><%= t("admin.layout.title") %></title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=4">
<link rel="stylesheet" href="/admin.css?v=6">
<% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42">
<% end %>
@@ -25,6 +25,7 @@
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
<%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
<% end %>
<%= link_to t("admin.layout.nav.announcements"), admin_announcements_path, class: ("active" if controller_name == "announcements") %>
<%= link_to t("admin.layout.nav.clubs"), admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
+2 -1
View File
@@ -8,11 +8,12 @@
<%= render "shared/meta_tags" %>
<%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=64">
<link rel="stylesheet" href="/marketing.css?v=67">
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
<%= render "shared/site_announcements" %>
<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 %>
@@ -6,13 +6,14 @@
<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=64">
<link rel="stylesheet" href="/marketing.css?v=67">
<link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %>
</head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %>
<%= render "shared/marketing_nav" %>
<%= render "shared/site_announcements" %>
<main class="live-main">
<%= yield %>
</main>
+1 -1
View File
@@ -1,7 +1,7 @@
<% content_for :title, t("club.edit.title", name: @club.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap" style="padding-top:20px;max-width:560px">
<div class="wrap wrap--portal" style="padding-top:20px">
<h1><%= t("club.edit.heading") %></h1>
<div class="card">
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
@@ -0,0 +1,124 @@
<% content_for :title, t("pages.contacts.meta_title") %>
<% content_for :meta_description, t("pages.contacts.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_contatti_path) %>
<article class="contacts-page">
<header class="features-hero contacts-hero">
<div class="wrap">
<p class="features-hero__eyebrow"><%= t("pages.contacts.eyebrow") %></p>
<h1><%= t("pages.contacts.title") %></h1>
<p class="features-hero__lead"><%= t("pages.contacts.lead") %></p>
<p class="contacts-hero__meta"><%= t("pages.contacts.response_time") %></p>
</div>
</header>
<section class="section wrap" aria-label="<%= t("pages.contacts.title") %>">
<div class="contacts-channels">
<article class="feature-card">
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-briefcase"></i></span>
<h2><%= t("pages.contacts.commercial_title") %></h2>
<p><%= t("pages.contacts.commercial_body") %></p>
<p class="contacts-channels__email">
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
</p>
</article>
<article class="feature-card">
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-life-ring"></i></span>
<h2><%= t("pages.contacts.support_title") %></h2>
<p>
<%= raw t(
"pages.contacts.support_body_html",
faq_link: link_to(t("pages.contacts.faq_link"), public_faq_path),
support_link: link_to(t("pages.contacts.support_link"), public_support_path)
) %>
</p>
<p class="contacts-channels__email">
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
</p>
</article>
<article class="feature-card">
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-shield-halved"></i></span>
<h2><%= t("pages.contacts.privacy_title") %></h2>
<p><%= t("pages.contacts.privacy_body") %></p>
<p class="contacts-channels__email">
<%= mail_to MatchLiveTv.privacy_controller_email, MatchLiveTv.privacy_controller_email %>
</p>
</article>
</div>
</section>
<section class="section wrap" aria-labelledby="contacts-company-title">
<div class="features-panel contacts-company">
<h2 id="contacts-company-title"><%= t("pages.contacts.company_title") %></h2>
<p class="features-panel__lead"><%= t("pages.contacts.company_lead") %></p>
<ul class="contacts-company__details">
<li><strong><%= MatchLiveTv.privacy_controller_name %></strong></li>
<li><%= t("pages.contacts.label_address") %>: <%= MatchLiveTv.privacy_controller_address %></li>
<% if MatchLiveTv.privacy_controller_vat.present? %>
<li><%= t("pages.contacts.label_vat") %>: <%= MatchLiveTv.privacy_controller_vat %></li>
<% end %>
</ul>
</div>
</section>
<section class="section wrap" aria-labelledby="contacts-form-title">
<div class="contacts-form-panel">
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
<p class="contacts-form-panel__lead"><%= t("pages.contacts.form_lead") %></p>
<%= form_with url: public_contatti_path, method: :post, local: true, class: "contacts-form" do %>
<div class="contacts-hp" aria-hidden="true">
<label for="website"><%= t("pages.contacts.honeypot_label") %></label>
<%= text_field_tag :website, @inquiry[:website], tabindex: -1, autocomplete: "off" %>
</div>
<%= label_tag :name, t("pages.contacts.name_label") %>
<%= text_field_tag :name, @inquiry[:name], required: true, maxlength: 120, autocomplete: "name" %>
<%= label_tag :email, t("pages.contacts.email_label") %>
<%= email_field_tag :email, @inquiry[:email], required: true, autocomplete: "email" %>
<%= label_tag :club_name, t("pages.contacts.club_label") %>
<%= text_field_tag :club_name, @inquiry[:club_name], maxlength: 160, autocomplete: "organization" %>
<%= label_tag :topic, t("pages.contacts.topic_label") %>
<%= select_tag :topic, options_for_select(
[
[t("pages.contacts.topic_commercial"), "commercial"],
[t("pages.contacts.topic_support"), "support"],
[t("pages.contacts.topic_privacy"), "privacy"]
],
@inquiry[:topic]
), required: true %>
<%= label_tag :message, t("pages.contacts.message_label") %>
<%= text_area_tag :message, @inquiry[:message], required: true, rows: 6, maxlength: 4000 %>
<label class="legal-accept">
<%= check_box_tag :accept_privacy, "1", @inquiry[:accept_privacy] == "1", required: true %>
<span class="legal-accept-text">
<%= raw t(
"pages.contacts.privacy_accept_html",
privacy_link: link_to(t("pages.contacts.privacy_link"), public_privacy_path, target: "_blank", rel: "noopener")
) %>
</span>
</label>
<%= submit_tag t("pages.contacts.submit"), class: "btn btn-primary" %>
<% end %>
</div>
</section>
<section class="section wrap features-cta" aria-labelledby="contacts-cta-title">
<div class="features-cta__box">
<h2 id="contacts-cta-title"><%= t("pages.contacts.cta_title") %></h2>
<p><%= t("pages.contacts.cta_body") %></p>
<div class="features-cta__actions">
<%= link_to t("pages.contacts.cta_faq"), public_faq_path, class: "btn btn-outline" %>
<%= link_to t("pages.contacts.cta_signup"), public_signup_path, class: "btn btn-primary" %>
</div>
</div>
</section>
</article>
@@ -83,6 +83,6 @@
<div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
<%= link_to t("pages.pricing.contact_button"), public_contatti_path, class: "btn btn-primary" %>
</div>
</div>
@@ -43,6 +43,7 @@
<li><%= raw t("legal.privacy.s3_item4_html") %></li>
<li><%= raw t("legal.privacy.s3_item5_html") %></li>
<li><%= raw t("legal.privacy.s3_item6_html") %></li>
<li><%= raw t("legal.privacy.s3_item7_html") %></li>
</ul>
</section>
@@ -107,6 +108,10 @@
<td><%= t("legal.privacy.s5_row6_purpose") %></td>
<td><%= t("legal.privacy.s5_row6_basis") %></td>
</tr>
<tr>
<td><%= t("legal.privacy.s5_row7_purpose") %></td>
<td><%= t("legal.privacy.s5_row7_basis") %></td>
</tr>
</tbody>
</table>
</div>
@@ -144,6 +149,7 @@
<li><%= raw t("legal.privacy.s8_item2_html") %></li>
<li><%= raw t("legal.privacy.s8_item3_html") %></li>
<li><%= raw t("legal.privacy.s8_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
<li><%= raw t("legal.privacy.s8_item5_html") %></li>
</ul>
</section>
+1 -1
View File
@@ -1,7 +1,7 @@
<% content_for :title, t("team.edit.title", name: @team.name) %>
<% content_for :robots, "noindex, nofollow" %>
<div class="wrap" style="padding-top:20px;max-width:560px">
<div class="wrap wrap--portal" style="padding-top:20px">
<h1><%= @team.name %></h1>
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
<div class="card">
+46 -19
View File
@@ -1,7 +1,9 @@
<% record = local_assigns[:record] %>
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
<% can_upload = local_assigns.fetch(:can_upload, false) %>
<% cover_src = record.effective_cover_url.presence || Streams::CoverResolver::DEFAULT_COVER_URL %>
<% default_cover = Streams::CoverResolver::DEFAULT_COVER_URL %>
<% cover_src = record.effective_cover_url.presence || default_cover %>
<% remove_id = "remove_cover_#{record.model_name.param_key}" %>
<fieldset class="branding-fields cover-fields">
<legend><%= t("club.branding.cover_legend") %></legend>
@@ -22,16 +24,25 @@
</div>
<% if can_upload %>
<%= file_field_tag "branding[cover_image]",
accept: "image/png,image/jpeg,image/webp",
data: { branding_cover_file: true },
class: "branding-file-input" %>
<% if record.cover_image_attached? %>
<label class="checkbox-label cover-remove-label">
<%= check_box_tag "remove_cover", "1", false, id: "remove_cover_#{record.model_name.param_key}" %>
<%= t("club.branding.cover_remove_label") %>
</label>
<% end %>
<div class="cover-actions">
<%= file_field_tag "branding[cover_image]",
accept: "image/png,image/jpeg,image/webp",
data: { branding_cover_file: true },
class: "branding-file-input" %>
<% if record.cover_image_attached? %>
<%= hidden_field_tag "remove_cover", "0", id: remove_id, data: { branding_cover_remove: true } %>
<button type="button"
class="btn btn-secondary cover-reset-btn"
data-branding-cover-reset
data-default-cover="<%= default_cover %>"
data-remove-input="#<%= remove_id %>">
<%= t("club.branding.cover_reset_button") %>
</button>
<p class="branding-hint cover-reset-hint" data-branding-cover-reset-hint hidden>
<%= t("club.branding.cover_reset_pending_hint") %>
</p>
<% end %>
</div>
<% else %>
<p class="muted"><%= t("club.branding.cover_requires_full") %></p>
<%= link_to t("club.branding.cover_see_plans"), public_prezzi_path, class: "btn btn-secondary btn-sm" %>
@@ -43,13 +54,29 @@
(function () {
var input = document.querySelector("[data-branding-cover-file]");
var img = document.querySelector("[data-branding-cover-img]");
if (!input || !img) return;
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function (e) { img.src = e.target.result; };
reader.readAsDataURL(file);
});
var resetBtn = document.querySelector("[data-branding-cover-reset]");
var removeInput = document.querySelector("[data-branding-cover-remove]");
var resetHint = document.querySelector("[data-branding-cover-reset-hint]");
if (input && img) {
input.addEventListener("change", function () {
var file = input.files && input.files[0];
if (!file) return;
if (removeInput) removeInput.value = "0";
if (resetHint) resetHint.hidden = true;
if (resetBtn) resetBtn.disabled = false;
var reader = new FileReader();
reader.onload = function (e) { img.src = e.target.result; };
reader.readAsDataURL(file);
});
}
if (resetBtn && img && removeInput) {
resetBtn.addEventListener("click", function () {
removeInput.value = "1";
img.src = resetBtn.getAttribute("data-default-cover");
if (input) input.value = "";
if (resetHint) resetHint.hidden = false;
resetBtn.disabled = true;
});
}
})();
</script>
@@ -5,6 +5,7 @@
<%= render "shared/store_badges", variant: "footer" %>
</div>
<div>
<%= link_to t("common.contacts"), public_contatti_path %> ·
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> ·
@@ -0,0 +1,45 @@
<% announcements = @site_announcements %>
<% if announcements.present? %>
<div class="site-announcements" role="region" aria-label="<%= t("announcement.region") %>">
<% announcements.each do |item| %>
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
data-announcement-id="<%= item.id %>">
<div class="site-announcement__body">
<p class="site-announcement__title"><%= item.title %></p>
<div class="site-announcement__text"><%= simple_format(item.body) %></div>
<% if item.action_url.present? %>
<p class="site-announcement__action">
<%= link_to (item.action_label.presence || t("announcement.open")), item.action_url,
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
</p>
<% end %>
</div>
<% if item.dismissible? %>
<button type="button" class="site-announcement__dismiss" data-announcement-dismiss aria-label="<%= t("announcement.dismiss") %>">
×
</button>
<% end %>
</article>
<% end %>
</div>
<script>
(function () {
var key = "mltv.webDismissedAnnouncements";
var dismissed = [];
try { dismissed = JSON.parse(localStorage.getItem(key) || "[]"); } catch (e) {}
document.querySelectorAll(".site-announcement[data-announcement-id]").forEach(function (el) {
if (dismissed.indexOf(el.getAttribute("data-announcement-id")) !== -1) el.remove();
});
document.querySelectorAll("[data-announcement-dismiss]").forEach(function (btn) {
btn.addEventListener("click", function () {
var box = btn.closest("[data-announcement-id]");
if (!box) return;
var id = box.getAttribute("data-announcement-id");
if (dismissed.indexOf(id) === -1) dismissed.push(id);
localStorage.setItem(key, JSON.stringify(dismissed));
box.remove();
});
});
})();
</script>
<% end %>
+65
View File
@@ -5,6 +5,7 @@ de:
nav:
dashboard: Dashboard
ops: Ops
announcements: Hinweise
clubs: Vereine & Teams
billing: Abrechnung
youtube: YouTube
@@ -45,6 +46,9 @@ de:
invoice_updated: "Rechnung %{number} aktualisiert."
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert
announcement_destroyed: Hinweis gelöscht
common:
free_plan: Free
yes: "Ja"
@@ -360,3 +364,64 @@ de:
club_recordings:
index:
title: "Replays — %{club}"
announcements:
index:
title: Hinweise
lead: Meldungen für die Apps, die öffentliche Website und den Login-Bereich — Wartung, Updates oder Mitteilungen.
new: Neuer Hinweis
table_title: Hinweise
none: Noch keine Hinweise. Erstelle einen, um Apps und Website zu informieren.
new:
title: Neuer Hinweis
edit:
title: Hinweis bearbeiten
table:
status: Status
kind: Typ
title: Nachricht
channels: Ziele
window: Zeitraum
actions:
edit: Bearbeiten
delete: Löschen
delete_confirm: Diesen Hinweis löschen? Er erscheint nicht mehr an den gewählten Stellen.
kind:
info: Information
maintenance: Wartung
update: App-Update
severity:
info: Info
warning: Warnung
critical: Kritisch
status:
draft: Entwurf
published: Veröffentlicht
archived: Archiviert
channels:
app: Apps (Android und iOS)
web_public: Öffentliche Website
web_private: Login-Bereich
window:
always: Immer (solange veröffentlicht)
from: "Ab %{time}"
until: "Bis %{time}"
form:
kind: Typ
severity: Schwere
status: Status
channels: Wo anzeigen
channels_hint: Eine oder mehrere Ziele wählen. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto).
title: Titel
body: Text
starts_at: Sichtbar ab
ends_at: Sichtbar bis
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
action_url: Link (optional)
action_url_hint: Bei Updates öffnet eine leere URL den Store. Sonst eine https-URL.
action_label: Button-Text
dismissible: Nutzer können den Hinweis schließen
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
submit_create: Hinweis speichern
submit_update: Hinweis aktualisieren
cancel: Abbrechen
+65
View File
@@ -5,6 +5,7 @@ en:
nav:
dashboard: Dashboard
ops: Ops
announcements: Notices
clubs: Clubs & teams
billing: Billing
youtube: YouTube
@@ -45,6 +46,9 @@ en:
invoice_updated: "Invoice %{number} updated."
invoice_uploaded: "Invoice uploaded and sent to %{email}."
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
announcement_created: Notice saved
announcement_updated: Notice updated
announcement_destroyed: Notice deleted
common:
free_plan: Free
yes: "Yes"
@@ -360,3 +364,64 @@ en:
club_recordings:
index:
title: "Replays — %{club}"
announcements:
index:
title: Notices
lead: Messages for the apps, the public website and the logged-in area — maintenance, updates or announcements.
new: New notice
table_title: Notices
none: No notices yet. Create one to notify the apps and the website.
new:
title: New notice
edit:
title: Edit notice
table:
status: Status
kind: Type
title: Message
channels: Destinations
window: Window
actions:
edit: Edit
delete: Delete
delete_confirm: Delete this notice? It will no longer appear in the selected destinations.
kind:
info: Information
maintenance: Maintenance
update: App update
severity:
info: Info
warning: Warning
critical: Critical
status:
draft: Draft
published: Published
archived: Archived
channels:
app: Apps (Android and iOS)
web_public: Public website
web_private: Logged-in area
window:
always: Always (while published)
from: "From %{time}"
until: "Until %{time}"
form:
kind: Type
severity: Severity
status: Status
channels: Where to show it
channels_hint: Select one or more destinations. The logged-in area is visible after sign-in (club, teams, account).
title: Title
body: Body
starts_at: Visible from
ends_at: Visible until
window_hint: Leave empty to show immediately while published.
action_url: Link (optional)
action_url_hint: For updates, an empty URL opens the store. Otherwise use an https URL.
action_label: Button label
dismissible: Users can dismiss the notice
dismissible_hint: Uncheck for maintenance notices that should stay visible.
submit_create: Save notice
submit_update: Update notice
cancel: Cancel
+65
View File
@@ -5,6 +5,7 @@ es:
nav:
dashboard: Panel
ops: Ops
announcements: Avisos
clubs: Clubes y equipos
billing: Facturación
youtube: YouTube
@@ -45,6 +46,9 @@ es:
invoice_updated: "Factura %{number} actualizada."
invoice_uploaded: "Factura subida y enviada a %{email}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
announcement_created: Aviso guardado
announcement_updated: Aviso actualizado
announcement_destroyed: Aviso eliminado
common:
free_plan: Free
yes: "Sí"
@@ -360,3 +364,64 @@ es:
club_recordings:
index:
title: "Repeticiones — %{club}"
announcements:
index:
title: Avisos
lead: Mensajes hacia las apps, el sitio público y el área privada — mantenimiento, actualizaciones o comunicados.
new: Nuevo aviso
table_title: Avisos
none: Todavía no hay avisos. Crea uno para notificar a las apps y al sitio.
new:
title: Nuevo aviso
edit:
title: Editar aviso
table:
status: Estado
kind: Tipo
title: Mensaje
channels: Destinos
window: Ventana
actions:
edit: Editar
delete: Eliminar
delete_confirm: ¿Eliminar este aviso? Ya no se verá en los destinos elegidos.
kind:
info: Información
maintenance: Mantenimiento
update: Actualización
severity:
info: Info
warning: Atención
critical: Crítico
status:
draft: Borrador
published: Publicado
archived: Archivado
channels:
app: Apps (Android e iOS)
web_public: Sitio público
web_private: Área privada
window:
always: Siempre (mientras esté publicado)
from: "Desde %{time}"
until: "Hasta %{time}"
form:
kind: Tipo
severity: Gravedad
status: Estado
channels: Dónde mostrarlo
channels_hint: Elige uno o más destinos. El área privada se ve tras iniciar sesión (club, equipos, cuenta).
title: Título
body: Texto
starts_at: Visible desde
ends_at: Visible hasta
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
action_url: Enlace (opcional)
action_url_hint: En actualizaciones, una URL vacía abre la tienda. Si no, usa una URL https.
action_label: Texto del botón
dismissible: El usuario puede cerrar el aviso
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
submit_create: Guardar aviso
submit_update: Actualizar aviso
cancel: Cancelar
+65
View File
@@ -5,6 +5,7 @@ fr:
nav:
dashboard: Tableau de bord
ops: Ops
announcements: Alertes
clubs: Clubs et équipes
billing: Facturation
youtube: YouTube
@@ -45,6 +46,9 @@ fr:
invoice_updated: "Facture %{number} mise à jour."
invoice_uploaded: "Facture chargée et envoyée à %{email}."
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour
announcement_destroyed: Alerte supprimée
common:
free_plan: Free
yes: "Oui"
@@ -360,3 +364,64 @@ fr:
club_recordings:
index:
title: "Replays — %{club}"
announcements:
index:
title: Alertes
lead: Messages vers les apps, le site public et l'espace connecté — maintenance, mises à jour ou communications.
new: Nouvelle alerte
table_title: Alertes
none: Aucune alerte. Créez-en une pour notifier les applications et le site.
new:
title: Nouvelle alerte
edit:
title: Modifier l'alerte
table:
status: Statut
kind: Type
title: Message
channels: Destinations
window: Fenêtre
actions:
edit: Modifier
delete: Supprimer
delete_confirm: Supprimer cette alerte ? Elle n'apparaîtra plus aux destinations choisies.
kind:
info: Information
maintenance: Maintenance
update: Mise à jour
severity:
info: Info
warning: Attention
critical: Critique
status:
draft: Brouillon
published: Publié
archived: Archivé
channels:
app: Apps (Android et iOS)
web_public: Site public
web_private: Espace connecté
window:
always: Toujours (tant que publié)
from: "À partir de %{time}"
until: "Jusqu'à %{time}"
form:
kind: Type
severity: Gravité
status: Statut
channels: Où l'afficher
channels_hint: Choisissez une ou plusieurs destinations. L'espace connecté est visible après connexion (club, équipes, compte).
title: Titre
body: Texte
starts_at: Visible à partir de
ends_at: Visible jusqu'à
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
action_url: Lien (facultatif)
action_url_hint: Pour une mise à jour, une URL vide ouvre le store. Sinon une URL https.
action_label: Libellé du bouton
dismissible: L'utilisateur peut fermer l'alerte
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
submit_create: Enregistrer l'alerte
submit_update: Mettre à jour l'alerte
cancel: Annuler
+65
View File
@@ -5,6 +5,7 @@ it:
nav:
dashboard: Dashboard
ops: Ops
announcements: Avvisi
clubs: Società e squadre
billing: Fatturazione
youtube: YouTube
@@ -49,6 +50,9 @@ it:
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato.
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato
announcement_destroyed: Avviso eliminato
common:
free_plan: Free
yes: "Sì"
@@ -398,3 +402,64 @@ it:
club_recordings:
index:
title: "Replay — %{club}"
announcements:
index:
title: Avvisi
lead: Messaggi verso le app, il sito pubblico e l'area riservata — manutenzione, aggiornamenti o comunicazioni.
new: Nuovo avviso
table_title: Avvisi
none: Nessun avviso. Crea il primo per notificare app e sito.
new:
title: Nuovo avviso
edit:
title: Modifica avviso
table:
status: Stato
kind: Tipo
title: Messaggio
channels: Destinazioni
window: Finestra
actions:
edit: Modifica
delete: Elimina
delete_confirm: Eliminare questo avviso? Non sarà più visibile nelle destinazioni scelte.
kind:
info: Informazione
maintenance: Manutenzione
update: Aggiornamento app
severity:
info: Info
warning: Attenzione
critical: Critico
status:
draft: Bozza
published: Pubblicato
archived: Archiviato
channels:
app: App (Android e iOS)
web_public: Sito pubblico
web_private: Area riservata
window:
always: Sempre (finché pubblicato)
from: "Da %{time}"
until: "Fino a %{time}"
form:
kind: Tipo
severity: Gravità
status: Stato
channels: Dove mostrarlo
channels_hint: Puoi selezionare una o più destinazioni. L'area riservata è visibile dopo il login (società, squadre, account).
title: Titolo
body: Testo
starts_at: Inizio visibilità
ends_at: Fine visibilità
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
action_url: Link (facoltativo)
action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store. Altrimenti un URL https.
action_label: Etichetta del pulsante
dismissible: L'utente può chiudere l'avviso
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
submit_create: Salva avviso
submit_update: Aggiorna avviso
cancel: Annulla
+2
View File
@@ -21,6 +21,8 @@ de:
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
too_long: "ist zu lang (höchstens %{count} Zeichen)"
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
app_announcement:
no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
club:
back_to_club: "← Verein"
sport_label: Hauptsportart
+4
View File
@@ -16,6 +16,8 @@ en:
too_short: "is too short (minimum is %{count} characters)"
too_long: "is too long (maximum is %{count} characters)"
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
app_announcement:
no_channel: Select at least one destination (apps, public website or logged-in area).
club:
back_to_club: "← Club"
sport_label: Main sport
@@ -39,6 +41,8 @@ en:
cover_file_label: Cover image
cover_preview_alt: Live cover preview
cover_remove_label: Remove custom cover
cover_reset_button: Restore standard cover
cover_reset_pending_hint: The standard cover will be restored when you save.
cover_requires_full: Custom sponsor covers are available on the Premium Full plan.
cover_see_plans: See plans
dashboard:
+2
View File
@@ -21,6 +21,8 @@ es:
too_short: "es demasiado corta (mínimo %{count} caracteres)"
too_long: "es demasiado larga (máximo %{count} caracteres)"
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
app_announcement:
no_channel: Elige al menos un destino (apps, sitio público o área privada).
club:
back_to_club: "← Club"
sport_label: Deporte principal
+2
View File
@@ -21,6 +21,8 @@ fr:
too_short: "est trop court (minimum %{count} caractères)"
too_long: "est trop long (maximum %{count} caractères)"
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
app_announcement:
no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
club:
back_to_club: "← Club"
sport_label: Sport principal
+4
View File
@@ -16,6 +16,8 @@ it:
too_short: "è troppo corta (minimo %{count} caratteri)"
too_long: "è troppo lunga (massimo %{count} caratteri)"
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
app_announcement:
no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
club:
back_to_club: "← Società"
sport_label: Sport principale
@@ -39,6 +41,8 @@ it:
cover_file_label: Immagine copertina
cover_preview_alt: Anteprima copertina diretta
cover_remove_label: Rimuovi copertina personalizzata
cover_reset_button: Ripristina copertina standard
cover_reset_pending_hint: Verrà ripristinata la copertina standard al salvataggio.
cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full.
cover_see_plans: Vedi piani
dashboard:
+1
View File
@@ -28,6 +28,7 @@ de:
s3_item4_html: "<strong>Audiovisuelle Inhalte:</strong> Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
s3_item5_html: "<strong>Zahlungsdaten:</strong> Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
s3_item6_html: "<strong>Nutzungsdaten:</strong> Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
s3_item7_html: "<strong>Daten des Kontaktformulars:</strong> Name, E-Mail, optionaler Verein, Anliegen und Nachricht von der Kontaktseite."
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare <strong>minderjährige Athleten</strong> erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
+3
View File
@@ -28,6 +28,7 @@ en:
s3_item4_html: "<strong>Audiovisual content:</strong> video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
s3_item5_html: "<strong>Payment data:</strong> for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
s3_item6_html: "<strong>Browsing data:</strong> server logs, IP address, user agent, needed for the security and operation of the site."
s3_item7_html: "<strong>Contact form data:</strong> name, email, optional club, topic and message submitted from the Contact page."
s4_title: 4. Processing of images and data of minor athletes
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable <strong>minor athletes</strong> (image, voice, jersey number, game data)."
s4_lead1: Who is responsible for processing images of minors
@@ -56,6 +57,8 @@ en:
s5_row5_basis: Performance of a contract
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
s5_row6_basis: Consent or legitimate interest, depending on the case
s5_row7_purpose: Handling requests sent via the contact form
s5_row7_basis: Pre-contractual measures; legitimate interest in answering enquiries
s6_title: 6. Recipients and data processors
s6_intro: "Data may be shared with providers acting as data processors, including:"
s6_item1: hosting and cloud/server infrastructure provider;
+1
View File
@@ -28,6 +28,7 @@ es:
s3_item4_html: "<strong>Contenidos audiovisuales:</strong> flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
s3_item5_html: "<strong>Datos de pago:</strong> para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
s3_item6_html: "<strong>Datos de navegación:</strong> registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
s3_item7_html: "<strong>Datos del formulario de contacto:</strong> nombre, email, club opcional, motivo y mensaje enviados desde la página de Contacto."
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a <strong>deportistas menores de edad</strong> identificables (imagen, voz, número de camiseta, datos de juego)."
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
+1
View File
@@ -28,6 +28,7 @@ fr:
s3_item4_html: "<strong>Contenus audiovisuels :</strong> flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
s3_item5_html: "<strong>Données de paiement :</strong> pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
s3_item6_html: "<strong>Données de navigation :</strong> journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
s3_item7_html: "<strong>Données du formulaire de contact :</strong> nom, email, club éventuel, motif et message envoyés depuis la page Contact."
s4_title: 4. Traitement des images et données des athlètes mineurs
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des <strong>athlètes mineurs</strong> identifiables (image, voix, numéro de maillot, données de jeu)."
s4_lead1: Qui est responsable du traitement des images des mineurs
+3
View File
@@ -28,6 +28,7 @@ it:
s3_item4_html: "<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."
s3_item5_html: "<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."
s3_item6_html: "<strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
s3_item7_html: "<strong>Dati del modulo contatti:</strong> nome, email, eventuale società, motivo e messaggio inviati dalla pagina Contatti."
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
s4_p1_html: "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)."
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
@@ -56,6 +57,8 @@ it:
s5_row5_basis: Esecuzione del contratto
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
s5_row7_purpose: Gestione delle richieste inviate dal modulo contatti
s5_row7_basis: Misure precontrattuali; legittimo interesse a rispondere alle richieste
s6_title: 6. Destinatari e responsabili del trattamento
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
s6_item1: fornitore di hosting e infrastruttura cloud/server;
+37
View File
@@ -175,3 +175,40 @@ de:
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
cta_start: Kostenlos starten
cta_faq: Häufige Fragen
contacts:
meta_title: "Kontakt — Match Live TV"
meta_description: "Kontakt zu Match Live TV: Angebote für Sportvereine, technischer Support und Datenschutzanfragen. Sitz in Mailand."
eyebrow: Match Live TV
title: Kontakt
lead: Schreib uns für ein Angebot, App-Support oder eine Datenschutzanfrage. Wir antworten in der Regel innerhalb eines Werktags.
response_time: "Typische Antwortzeit: 1 Werktag"
commercial_title: Vertrieb
commercial_body: Maßgeschneiderte Tarife, mehrere Teams, Turniere oder Integrationen. Schreib an
support_title: Support
support_body_html: "Probleme mit Zugang, Livestream oder App. Sieh zuerst die %{faq_link} oder die Seite %{support_link} an oder schreib an"
support_link: Support
faq_link: FAQ
privacy_title: Datenschutz
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
company_title: Sitz
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
label_address: Adresse
label_vat: USt-IdNr.
form_title: Nachricht senden
form_lead: Fülle das Formular aus: Wir antworten an die angegebene E-Mail-Adresse.
name_label: Name
email_label: E-Mail
club_label: Sportverein (optional)
topic_label: Anliegen
topic_commercial: Vertrieb / Angebot
topic_support: Technischer Support
topic_privacy: Datenschutz / DSGVO
message_label: Nachricht
honeypot_label: Website
privacy_accept_html: Ich stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu, wie in der %{privacy_link} beschrieben.
privacy_link: Datenschutzerklärung
submit: Nachricht senden
cta_title: Lieber selbst nachsehen?
cta_body: Viele Antworten stehen bereits in den FAQ. Um mit dem Streamen zu starten, registriere den Verein.
cta_faq: FAQ
cta_signup: Team registrieren
+37
View File
@@ -175,3 +175,40 @@ en:
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
cta_start: Start for free
cta_faq: Frequently asked questions
contacts:
meta_title: "Contact — Match Live TV"
meta_description: "Contact Match Live TV: quotes for sports clubs, technical support and privacy requests. Based in Milan."
eyebrow: Match Live TV
title: Contact
lead: Write to us for a quote, app support or a privacy request. We usually reply within one working day.
response_time: "Typical reply time: 1 working day"
commercial_title: Sales
commercial_body: Custom plans, multiple teams, tournaments or integrations. Write to
support_title: Support
support_body_html: "Sign-in, live stream or app issues. Check the %{faq_link} or the %{support_link} page first, or write to"
support_link: Support
faq_link: FAQ
privacy_title: Privacy
privacy_body: GDPR rights, consent and personal data. Write to
company_title: Company details
company_lead: Service provider and data controller.
label_address: Address
label_vat: VAT number
form_title: Send a message
form_lead: Fill in the form and we will reply to the email you provide.
name_label: Name
email_label: Email
club_label: Sports club (optional)
topic_label: Topic
topic_commercial: Sales / quote
topic_support: Technical support
topic_privacy: Privacy / GDPR
message_label: Message
honeypot_label: Website
privacy_accept_html: I agree to the processing of my data to handle this request, as described in the %{privacy_link}.
privacy_link: privacy notice
submit: Send message
cta_title: Prefer to look it up yourself?
cta_body: Many answers are already in the FAQ. To start streaming, register your club.
cta_faq: FAQ
cta_signup: Register your team
+37
View File
@@ -175,3 +175,40 @@ es:
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
cta_start: Empieza gratis
cta_faq: Preguntas frecuentes
contacts:
meta_title: "Contacto — Match Live TV"
meta_description: "Contacta con Match Live TV: presupuestos para clubes deportivos, soporte técnico y solicitudes de privacidad. Sede en Milán."
eyebrow: Match Live TV
title: Contacto
lead: Escríbenos para un presupuesto, para ayuda con la app o para una solicitud de privacidad. Solemos responder en un día laborable.
response_time: "Tiempo de respuesta orientativo: 1 día laborable"
commercial_title: Comercial
commercial_body: Planes a medida, varios equipos, torneos o integraciones. Escribe a
support_title: Asistencia
support_body_html: "Problemas de acceso, directos o app. Consulta primero las %{faq_link} o la página %{support_link}, o escribe a"
support_link: Soporte
faq_link: FAQ
privacy_title: Privacidad
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
company_title: Sede
company_lead: Prestador del servicio y responsable del tratamiento.
label_address: Dirección
label_vat: NIF / IVA
form_title: Enviar un mensaje
form_lead: Completa el formulario: te responderemos al email que indiques.
name_label: Nombre
email_label: Email
club_label: Club deportivo (opcional)
topic_label: Motivo
topic_commercial: Comercial / presupuesto
topic_support: Asistencia técnica
topic_privacy: Privacidad / RGPD
message_label: Mensaje
honeypot_label: Sitio web
privacy_accept_html: Acepto el tratamiento de los datos para gestionar esta solicitud, como se describe en la %{privacy_link}.
privacy_link: política de privacidad
submit: Enviar mensaje
cta_title: "¿Prefieres buscarlo tú?"
cta_body: Muchas respuestas ya están en las FAQ. Para empezar a emitir, registra el club.
cta_faq: FAQ
cta_signup: Registrar el equipo
+37
View File
@@ -175,3 +175,40 @@ fr:
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
cta_start: Commencer gratuitement
cta_faq: Questions fréquentes
contacts:
meta_title: "Contact — Match Live TV"
meta_description: "Contactez Match Live TV : devis pour clubs sportifs, assistance technique et demandes privacy. Siège à Milan."
eyebrow: Match Live TV
title: Contact
lead: Écrivez-nous pour un devis, pour l'assistance de l'app ou pour une demande privacy. Nous répondons en général sous un jour ouvrable.
response_time: "Délai indicatif : 1 jour ouvrable"
commercial_title: Commercial
commercial_body: Offres sur mesure, plusieurs équipes, tournois ou intégrations. Écrivez à
support_title: Assistance
support_body_html: "Problèmes d'accès, de direct ou d'application. Consultez d'abord les %{faq_link} ou la page %{support_link}, ou écrivez à"
support_link: Support
faq_link: FAQ
privacy_title: Confidentialité
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
company_title: Siège
company_lead: Prestataire du service et responsable du traitement.
label_address: Adresse
label_vat: N° de TVA
form_title: Envoyer un message
form_lead: Remplissez le formulaire : nous vous répondons à l'email indiqué.
name_label: Nom
email_label: Email
club_label: Club sportif (facultatif)
topic_label: Motif
topic_commercial: Commercial / devis
topic_support: Assistance technique
topic_privacy: Confidentialité / RGPD
message_label: Message
honeypot_label: Site web
privacy_accept_html: J'accepte le traitement des données pour gérer cette demande, comme décrit dans la %{privacy_link}.
privacy_link: notice de confidentialité
submit: Envoyer le message
cta_title: Vous préférez chercher vous-même ?
cta_body: Beaucoup de réponses sont déjà dans la FAQ. Pour commencer à diffuser, inscrivez le club.
cta_faq: FAQ
cta_signup: Enregistrer l'équipe
+37
View File
@@ -175,3 +175,40 @@ it:
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
cta_start: Inizia gratis
cta_faq: Domande frequenti
contacts:
meta_title: "Contatti — Match Live TV"
meta_description: "Contatta Match Live TV: preventivi per società sportive, assistenza tecnica e richieste privacy. Sede a Milano."
eyebrow: Match Live TV
title: Contatti
lead: Scrivici per un preventivo, per l'assistenza sull'app o per una richiesta privacy. Rispondiamo in genere entro un giorno lavorativo.
response_time: "Risposta indicativa: 1 giorno lavorativo"
commercial_title: Commerciale
commercial_body: Piani su misura, più squadre, tornei o integrazioni. Scrivi a
support_title: Assistenza
support_body_html: "Problemi di accesso, dirette o app. Consulta prima le %{faq_link} o la pagina %{support_link}, oppure scrivi a"
support_link: Supporto
faq_link: FAQ
privacy_title: Privacy
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
company_title: Sede
company_lead: Titolare del servizio e del trattamento dei dati.
label_address: Indirizzo
label_vat: Partita IVA
form_title: Invia un messaggio
form_lead: Compila il modulo: ti rispondiamo all'email che indichi.
name_label: Nome
email_label: Email
club_label: Società sportiva (opzionale)
topic_label: Motivo
topic_commercial: Commerciale / preventivo
topic_support: Assistenza tecnica
topic_privacy: Privacy / GDPR
message_label: Messaggio
honeypot_label: Sito web
privacy_accept_html: Acconsento al trattamento dei dati per gestire questa richiesta, come descritto nella %{privacy_link}.
privacy_link: informativa privacy
submit: Invia messaggio
cta_title: Preferisci fare da solo?
cta_body: Molte risposte sono già nelle FAQ. Per iniziare a trasmettere, registra la società.
cta_faq: FAQ
cta_signup: Registra la squadra
+5
View File
@@ -141,6 +141,7 @@ de:
common:
privacy: Datenschutz
support: Support
contacts: Kontakt
cookies: Cookies
terms: AGB
pricing: Preise
@@ -174,3 +175,7 @@ de:
choice_youtube_hint_only: Entfernt nur das YouTube-Video
choice_both_label: Website und YouTube
choice_both_hint: Vollständige Löschung
announcement:
region: Hinweise
dismiss: Hinweis schließen
open: Öffnen
+5
View File
@@ -141,6 +141,7 @@ en:
common:
privacy: Privacy
support: Support
contacts: Contacts
cookies: Cookies
terms: Terms
pricing: Pricing
@@ -174,3 +175,7 @@ en:
choice_youtube_hint_only: Removes only the YouTube video
choice_both_label: Site and YouTube
choice_both_hint: Complete deletion
announcement:
region: Notices
dismiss: Dismiss notice
open: Open
+5
View File
@@ -141,6 +141,7 @@ es:
common:
privacy: Privacidad
support: Soporte
contacts: Contacto
cookies: Cookies
terms: Términos
pricing: Precios
@@ -174,3 +175,7 @@ es:
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
choice_both_label: Sitio y YouTube
choice_both_hint: Eliminación completa
announcement:
region: Avisos
dismiss: Cerrar aviso
open: Abrir
+5
View File
@@ -141,6 +141,7 @@ fr:
common:
privacy: Confidentialité
support: Support
contacts: Contacts
cookies: Cookies
terms: Conditions
pricing: Tarifs
@@ -174,3 +175,7 @@ fr:
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
choice_both_label: Site et YouTube
choice_both_hint: Suppression complète
announcement:
region: Alertes
dismiss: Fermer l'alerte
open: Ouvrir
+5
View File
@@ -141,6 +141,7 @@ it:
common:
privacy: Privacy
support: Supporto
contacts: Contatti
cookies: Cookie
terms: Termini
pricing: Prezzi
@@ -174,3 +175,7 @@ it:
choice_youtube_hint_only: Rimuove solo il video YouTube
choice_both_label: Da sito e YouTube
choice_both_hint: Eliminazione completa
announcement:
region: Avvisi
dismiss: Chiudi avviso
open: Apri
+5
View File
@@ -16,6 +16,7 @@ Rails.application.routes.draw do
patch "account", to: "accounts#update"
patch "account/password", to: "accounts#password"
get "sports", to: "sports#index"
get "announcements", to: "announcements#index"
get "invitations/:token", to: "invitations#show"
post "invitations/:token/accept", to: "invitations#accept"
@@ -84,6 +85,7 @@ Rails.application.routes.draw do
root to: "dashboard#index"
get "metrics", to: "dashboard#metrics"
resources :announcements, except: %i[show]
resources :ops, only: [:index], controller: "ops" do
member do
post :acknowledge
@@ -170,6 +172,9 @@ Rails.application.routes.draw do
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
get "prezzi", to: "pages#pricing", as: :prezzi
get "pricing", to: redirect("/prezzi")
get "contatti", to: "contacts#show", as: :contatti
post "contatti", to: "contacts#create"
get "contact", to: redirect("/contatti")
get "privacy", to: "pages#privacy", as: :privacy
get "support", to: "pages#support", as: :support
get "cookie", to: "pages#cookies", as: :cookies
@@ -0,0 +1,25 @@
class CreateAppAnnouncements < ActiveRecord::Migration[7.2]
def change
create_table :app_announcements, id: :uuid do |t|
t.string :kind, null: false, default: "info"
t.string :severity, null: false, default: "info"
t.string :status, null: false, default: "draft"
t.boolean :show_on_app, null: false, default: true
t.boolean :show_on_web_public, null: false, default: false
t.boolean :show_on_web_private, null: false, default: false
t.string :title, null: false
t.text :body, null: false
t.string :action_url
t.string :action_label
t.boolean :dismissible, null: false, default: true
t.datetime :starts_at
t.datetime :ends_at
t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
t.timestamps
end
add_index :app_announcements, :status
add_index :app_announcements, %i[status starts_at ends_at]
end
end
+24 -1
View File
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
ActiveRecord::Schema[7.2].define(version: 2026_08_20_140000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -51,6 +51,28 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
end
create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "kind", default: "info", null: false
t.string "severity", default: "info", null: false
t.string "status", default: "draft", null: false
t.boolean "show_on_app", default: true, null: false
t.boolean "show_on_web_public", default: false, null: false
t.boolean "show_on_web_private", default: false, null: false
t.string "title", null: false
t.text "body", null: false
t.string "action_url"
t.string "action_label"
t.boolean "dismissible", default: true, null: false
t.datetime "starts_at"
t.datetime "ends_at"
t.uuid "created_by_admin_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_admin_id"], name: "index_app_announcements_on_created_by_admin_id"
t.index ["status", "starts_at", "ends_at"], name: "index_app_announcements_on_status_and_starts_at_and_ends_at"
t.index ["status"], name: "index_app_announcements_on_status"
end
create_table "billing_club_quotes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.string "plan_slug", null: false
@@ -471,6 +493,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "app_announcements", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "clubs"
add_foreign_key "billing_invoices", "billing_payments"
+83
View File
@@ -582,3 +582,86 @@ body.admin-body {
color: var(--red) !important;
font-weight: 600;
}
.admin-form {
display: grid;
gap: 0.9rem;
max-width: 720px;
}
.admin-form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.admin-form label {
display: block;
font-size: 0.8rem;
color: var(--muted);
margin-bottom: 0.3rem;
}
.admin-form input:not([type="checkbox"]),
.admin-form select,
.admin-form textarea {
width: 100%;
padding: 0.5rem 0.65rem;
border: 1px solid #444;
border-radius: 6px;
background: #1a1a22;
color: #eee;
font: inherit;
}
.admin-form textarea {
min-height: 7rem;
resize: vertical;
}
.admin-form-hint {
margin: -0.4rem 0 0;
font-size: 0.8rem;
}
.admin-checkbox {
display: flex;
align-items: center;
gap: 0.55rem;
color: var(--text);
font-size: 0.9rem;
}
.admin-checkbox input[type="checkbox"] {
width: 1rem;
height: 1rem;
}
.admin-channels {
margin: 0;
padding: 0.85rem 1rem;
border: 1px solid #444;
border-radius: 8px;
display: grid;
gap: 0.55rem;
}
.admin-channels legend {
padding: 0 0.35rem;
font-size: 0.8rem;
color: var(--muted);
}
.admin-form-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
margin-top: 0.35rem;
}
@media (max-width: 720px) {
.admin-form-row {
grid-template-columns: 1fr;
}
}
+130 -6
View File
@@ -9,6 +9,9 @@ body {
a { color: #ff6b6b; text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 0 20px 48px; }
/* Form portale (società/squadra + copertina 16:9): usa tutta la larghezza desktop, non stile “mobile”. */
.wrap--portal { max-width: 1100px; }
.wrap--portal .card { max-width: none; }
.site-chrome {
position: sticky;
top: 0;
@@ -450,15 +453,19 @@ body.nav-menu-open { overflow: hidden; }
object-fit: contain;
}
.branding-cover-preview {
min-height: 140px;
min-height: 0;
aspect-ratio: 16 / 9;
width: 100%;
padding: 0;
overflow: hidden;
}
.branding-cover-preview__img {
max-width: 100%;
max-height: 180px;
width: auto;
height: auto;
max-width: none;
max-height: none;
width: 100%;
height: 100%;
border-radius: 8px;
object-fit: contain;
object-fit: cover;
}
.cover-remove-label {
display: flex;
@@ -468,6 +475,27 @@ body.nav-menu-open { overflow: hidden; }
font-size: 0.9rem;
color: #aaa;
}
.cover-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-top: 12px;
}
.cover-reset-btn {
padding: 10px 16px;
font-size: 0.9rem;
white-space: nowrap;
}
.cover-reset-btn:disabled {
opacity: 0.55;
cursor: default;
}
.cover-reset-hint {
flex: 1 1 100%;
margin: 0;
color: #a5f0b8;
}
.branding-file-input { margin-bottom: 0 !important; }
.branding-url-input { margin-bottom: 14px; }
.branding-color-row {
@@ -3257,3 +3285,99 @@ a.replay-archive__thumb:hover {
margin-top: 8px;
}
}
.site-announcements {
max-width: 1100px;
margin: 0 auto;
padding: 12px 20px 0;
display: grid;
gap: 10px;
}
.live-main .site-announcements {
max-width: none;
padding-left: 0;
padding-right: 0;
}
.site-announcement {
display: flex;
align-items: flex-start;
gap: 12px;
border-radius: 12px;
padding: 14px 16px;
border: 1px solid #3a3a48;
background: #16161e;
}
.site-announcement__body {
flex: 1;
min-width: 0;
}
.site-announcement__title {
margin: 0 0 4px;
font-weight: 700;
}
.site-announcement__text {
margin: 0;
color: #c8c8d4;
font-size: 0.95rem;
}
.site-announcement__text p {
margin: 0 0 0.4em;
}
.site-announcement__text p:last-child {
margin-bottom: 0;
}
.site-announcement__action {
margin: 8px 0 0;
}
.site-announcement__link {
font-weight: 700;
}
.site-announcement__dismiss {
flex-shrink: 0;
width: 32px;
height: 32px;
border: 0;
border-radius: 8px;
background: transparent;
color: #9a9aad;
font-size: 1.4rem;
line-height: 1;
cursor: pointer;
}
.site-announcement__dismiss:hover {
color: #fff;
background: #2a2a36;
}
.site-announcement--info {
border-color: #e53935;
background: rgba(229, 57, 53, 0.12);
}
.site-announcement--maintenance,
.site-announcement--warning {
border-color: #f5c518;
background: rgba(245, 197, 24, 0.12);
}
.site-announcement--update,
.site-announcement--success {
border-color: #22c55e;
background: rgba(34, 197, 94, 0.12);
}
.site-announcement--critical {
border-color: #e53935;
background: rgba(229, 57, 53, 0.16);
}
@@ -0,0 +1,58 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe AppAnnouncement do
def create_notice!(**attrs)
described_class.create!({
kind: "info",
severity: "info",
status: "published",
title: "Avviso",
body: "Testo",
show_on_app: true,
show_on_web_public: false,
show_on_web_private: false
}.merge(attrs))
end
it "is active on the app when published without a time window" do
item = create_notice!(kind: "maintenance", severity: "warning", title: "Manutenzione", body: "Domani dalle 2 alle 4.")
expect(described_class.for_app).to include(item)
expect(described_class.for_channel(:web_public)).not_to include(item)
end
it "hides drafts and expired notices" do
create_notice!(status: "draft", title: "Bozza", body: "Non visibile")
create_notice!(title: "Scaduto", body: "Fine", ends_at: 1.hour.ago)
live = create_notice!(kind: "update", title: "Aggiornamento", body: "Nuova versione")
expect(described_class.for_app.map(&:title)).to eq(["Aggiornamento"])
expect(live.as_api_json(platform: "android")[:action_url]).to eq(MatchLiveTv.play_store_url)
expect(live.as_api_json(platform: "ios")[:action_url]).to eq(MatchLiveTv.app_store_url)
end
it "filters by selected channels" do
public_item = create_notice!(
title: "Pubblico", show_on_app: false, show_on_web_public: true
)
private_item = create_notice!(
title: "Privato", show_on_app: false, show_on_web_private: true
)
app_item = create_notice!(title: "App")
expect(described_class.for_channel(:web_public)).to contain_exactly(public_item)
expect(described_class.for_channel(:web_private)).to contain_exactly(private_item)
expect(described_class.for_app).to contain_exactly(app_item)
end
it "requires at least one channel" do
item = described_class.new(
kind: "info", status: "draft", title: "X", body: "Y",
show_on_app: false, show_on_web_public: false, show_on_web_private: false
)
expect(item).not_to be_valid
expect(item.errors[:base]).to be_present
end
end
@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Admin announcements", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-news", password: "Password123") }
before do
post admin_login_path, params: { username: "ops-news", password: "Password123" }
end
it "crea e pubblica un avviso visibile alle app e al sito" do
post admin_announcements_path, params: {
app_announcement: {
kind: "update",
severity: "info",
status: "published",
title: "Nuova versione",
body: "Aggiorna l'app dallo store.",
dismissible: "1",
show_on_app: "1",
show_on_web_public: "1",
show_on_web_private: "0"
}
}
expect(response).to redirect_to(admin_announcements_path)
notice = AppAnnouncement.last
expect(notice.title).to eq("Nuova versione")
expect(notice).to be_published
expect(notice.show_on_app).to be(true)
expect(notice.show_on_web_public).to be(true)
expect(notice.show_on_web_private).to be(false)
get admin_announcements_path
expect(response.body).to include("Nuova versione")
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
end
it "elimina un avviso" do
item = AppAnnouncement.create!(
kind: "info", status: "published", title: "Da togliere", body: "Test"
)
delete admin_announcement_path(item)
expect(response).to redirect_to(admin_announcements_path)
expect(AppAnnouncement.find_by(id: item.id)).to be_nil
end
end
@@ -0,0 +1,39 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Api::V1::Announcements", type: :request do
it "GET /api/v1/announcements senza autenticazione restituisce solo gli avvisi attivi per l'app" do
AppAnnouncement.create!(
kind: "maintenance",
severity: "warning",
status: "published",
title: "Manutenzione stasera",
body: "Dalle 23:00 alle 01:00 il servizio sarà offline.",
show_on_app: true
)
AppAnnouncement.create!(
kind: "info",
status: "published",
title: "Solo sito",
body: "Non in app",
show_on_app: false,
show_on_web_public: true
)
AppAnnouncement.create!(
kind: "info",
status: "draft",
title: "Bozza",
body: "Non deve uscire"
)
get "/api/v1/announcements", params: { platform: "android" }
expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body.length).to eq(1)
expect(body.first["title"]).to eq("Manutenzione stasera")
expect(body.first["kind"]).to eq("maintenance")
expect(body.first["dismissible"]).to eq(true)
end
end
@@ -0,0 +1,48 @@
# frozen_string_literal: true
require "rails_helper"
RSpec.describe "Public site announcements", type: :request do
let!(:coach) do
User.create!(email: "news-coach@test.it", name: "Coach", password: "Password123", role: "coach")
end
it "mostra gli avvisi pubblici in home e non quelli privati" do
AppAnnouncement.create!(
kind: "maintenance", status: "published", title: "Manutenzione sito",
body: "Stasera il sito sarà offline.",
show_on_app: false, show_on_web_public: true, show_on_web_private: false
)
AppAnnouncement.create!(
kind: "info", status: "published", title: "Solo soci",
body: "Messaggio interno.",
show_on_app: false, show_on_web_public: false, show_on_web_private: true
)
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Manutenzione sito")
expect(response.body).not_to include("Solo soci")
end
it "mostra gli avvisi privati nell'area account dopo il login" do
AppAnnouncement.create!(
kind: "info", status: "published", title: "Solo soci",
body: "Messaggio interno.",
show_on_app: false, show_on_web_public: false, show_on_web_private: true
)
AppAnnouncement.create!(
kind: "info", status: "published", title: "Manutenzione sito",
body: "Stasera il sito sarà offline.",
show_on_app: false, show_on_web_public: true, show_on_web_private: false
)
post public_login_path, params: { email: coach.email, password: "Password123" }
get public_account_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Solo soci")
expect(response.body).not_to include("Manutenzione sito")
end
end
@@ -0,0 +1,18 @@
package com.matchlivetv.match_live_tv.core
import android.content.Context
class AnnouncementStore(context: Context) {
private val prefs = context.applicationContext.getSharedPreferences("announcements", Context.MODE_PRIVATE)
fun dismissedIds(): Set<String> =
prefs.getStringSet(KEY_DISMISSED, emptySet())?.toSet().orEmpty()
fun dismiss(id: String) {
prefs.edit().putStringSet(KEY_DISMISSED, dismissedIds() + id).apply()
}
companion object {
private const val KEY_DISMISSED = "dismissed_ids"
}
}
@@ -1,9 +1,11 @@
package com.matchlivetv.match_live_tv.data
import android.content.Context
import com.matchlivetv.match_live_tv.core.AnnouncementStore
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.TokenStore
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
import com.matchlivetv.match_live_tv.data.repository.AuthRepository
@@ -86,6 +88,14 @@ class AppContainer(context: Context) {
val matchRepository = MatchRepository(api, tokenStore, appContext)
val announcementStore = AnnouncementStore(appContext)
suspend fun visibleAnnouncements(): List<AnnouncementDto> {
val dismissed = announcementStore.dismissedIds()
return runCatching { api.announcements() }.getOrElse { emptyList() }
.filter { it.id !in dismissed }
}
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
@@ -414,3 +414,14 @@ data class TelemetryRequest(
val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
)
data class AnnouncementDto(
val id: String,
val kind: String,
val severity: String = "info",
val title: String,
val body: String,
val dismissible: Boolean = true,
@Json(name = "action_url") val actionUrl: String? = null,
@Json(name = "action_label") val actionLabel: String? = null,
)
@@ -8,6 +8,7 @@ import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
import retrofit2.http.Query
import okhttp3.MultipartBody
interface MatchLiveApi {
@@ -38,6 +39,9 @@ interface MatchLiveApi {
@GET("sports")
suspend fun sports(): List<SportDto>
@GET("announcements")
suspend fun announcements(@Query("platform") platform: String = "android"): List<AnnouncementDto>
@GET("teams")
suspend fun teams(): List<TeamDto>
@@ -0,0 +1,102 @@
package com.matchlivetv.match_live_tv.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Campaign
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Construction
import androidx.compose.material.icons.filled.SystemUpdate
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
@Composable
fun AnnouncementBanners(
announcements: List<AnnouncementDto>,
onDismiss: (String) -> Unit,
modifier: Modifier = Modifier,
) {
if (announcements.isEmpty()) return
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
announcements.forEach { item ->
AnnouncementBanner(item = item, onDismiss = { onDismiss(item.id) })
}
}
}
@Composable
private fun AnnouncementBanner(
item: AnnouncementDto,
onDismiss: () -> Unit,
) {
val uriHandler = LocalUriHandler.current
val accent = announcementAccent(item)
Column(
Modifier
.fillMaxWidth()
.background(accent.copy(alpha = 0.16f), RoundedCornerShape(12.dp))
.padding(14.dp),
) {
Row(verticalAlignment = Alignment.Top) {
Icon(announcementIcon(item.kind), contentDescription = null, tint = accent)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(item.title, color = accent, style = MaterialTheme.typography.titleMedium)
Spacer(Modifier.height(4.dp))
Text(item.body, style = MaterialTheme.typography.bodyMedium)
}
if (item.dismissible) {
IconButton(onClick = onDismiss) {
Icon(
Icons.Filled.Close,
contentDescription = stringResource(R.string.announcement_dismiss),
tint = MatchColors.TextSecondary,
)
}
}
}
val actionUrl = item.actionUrl?.takeIf { it.isNotBlank() }
if (actionUrl != null) {
TextButton(onClick = { runCatching { uriHandler.openUri(actionUrl) } }) {
Text(
item.actionLabel?.takeIf { it.isNotBlank() }
?: stringResource(R.string.announcement_open),
color = accent,
)
}
}
}
}
private fun announcementAccent(item: AnnouncementDto): Color = when {
item.severity == "critical" || item.kind == "maintenance" -> MatchColors.AccentYellow
item.kind == "update" -> MatchColors.SuccessGreen
else -> MatchColors.PrimaryRed
}
private fun announcementIcon(kind: String) = when (kind) {
"maintenance" -> Icons.Filled.Construction
"update" -> Icons.Filled.SystemUpdate
else -> Icons.Filled.Campaign
}
@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,6 +53,8 @@ import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -72,6 +75,7 @@ fun LoginScreen(
var loading by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
var showLanguagePicker by remember { mutableStateOf(false) }
var announcements by remember { mutableStateOf<List<AnnouncementDto>>(emptyList()) }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val errCredentials = stringResource(R.string.login_error_credentials)
@@ -98,6 +102,10 @@ fun LoginScreen(
}
}
LaunchedEffect(Unit) {
announcements = container.visibleAnnouncements()
}
MatchScreenScaffold(
topBar = {
TopAppBar(
@@ -142,6 +150,16 @@ fun LoginScreen(
Spacer(Modifier.height(16.dp))
}
MatchLiveWordmark(showSlogan = true)
if (announcements.isNotEmpty()) {
Spacer(Modifier.height(24.dp))
AnnouncementBanners(
announcements = announcements,
onDismiss = { id ->
container.announcementStore.dismiss(id)
announcements = announcements.filterNot { it.id == id }
},
)
}
Spacer(Modifier.height(48.dp))
Text(
text = stringResource(R.string.login_submit).uppercase(),
@@ -53,6 +53,8 @@ import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Team
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -86,6 +88,7 @@ fun MatchesScreen(
var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
var activeTeam by remember { mutableStateOf<Team?>(null) }
var matches by remember { mutableStateOf<List<Match>>(emptyList()) }
var announcements by remember { mutableStateOf<List<AnnouncementDto>>(emptyList()) }
var showNewMatchSheet by remember { mutableStateOf(false) }
var showScheduleSheet by remember { mutableStateOf(false) }
@@ -116,6 +119,7 @@ fun MatchesScreen(
}.onFailure {
error = it.message ?: msgLoadError
}
announcements = container.visibleAnnouncements()
loading = false
refreshing = false
}
@@ -192,7 +196,18 @@ fun MatchesScreen(
if (showLanguagePicker) {
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
}
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize()) {
if (announcements.isNotEmpty()) {
AnnouncementBanners(
announcements = announcements,
onDismiss = { id ->
container.announcementStore.dismiss(id)
announcements = announcements.filterNot { it.id == id }
},
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
)
}
Box(Modifier.weight(1f)) {
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { reload(showInitialSpinner = false) },
@@ -339,6 +354,7 @@ fun MatchesScreen(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}
}
@@ -14,6 +14,8 @@
<string name="action_cancel">Abbrechen</string>
<string name="action_confirm">Bestätigen</string>
<string name="action_save">Speichern</string>
<string name="announcement_dismiss">Hinweis schließen</string>
<string name="announcement_open">Öffnen</string>
<string name="login_email">E-Mail</string>
<string name="login_password">Passwort</string>
<string name="login_submit">Anmelden</string>
@@ -14,6 +14,8 @@
<string name="action_cancel">Cancel</string>
<string name="action_confirm">Confirm</string>
<string name="action_save">Save</string>
<string name="announcement_dismiss">Dismiss notice</string>
<string name="announcement_open">Open</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_submit">Log in</string>
@@ -14,6 +14,8 @@
<string name="action_cancel">Cancelar</string>
<string name="action_confirm">Confirmar</string>
<string name="action_save">Guardar</string>
<string name="announcement_dismiss">Cerrar aviso</string>
<string name="announcement_open">Abrir</string>
<string name="login_email">Email</string>
<string name="login_password">Contraseña</string>
<string name="login_submit">Acceder</string>
@@ -14,6 +14,8 @@
<string name="action_cancel">Annuler</string>
<string name="action_confirm">Confirmer</string>
<string name="action_save">Enregistrer</string>
<string name="announcement_dismiss">Fermer l\'alerte</string>
<string name="announcement_open">Ouvrir</string>
<string name="login_email">E-mail</string>
<string name="login_password">Mot de passe</string>
<string name="login_submit">Connexion</string>
@@ -15,6 +15,8 @@
<string name="action_cancel">Annulla</string>
<string name="action_confirm">Conferma</string>
<string name="action_save">Salva</string>
<string name="announcement_dismiss">Chiudi avviso</string>
<string name="announcement_open">Apri</string>
<string name="login_email">Email</string>
<string name="login_password">Password</string>
<string name="login_submit">Accedi</string>
@@ -108,6 +108,8 @@ enum L10n {
"api.error.unauthorized": "Sessione scaduta. Accedi di nuovo.",
"app.name": "Match Live TV",
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
"announcement.dismiss": "Chiudi avviso",
"announcement.open": "Apri",
"broadcast.close.set.button": "CHIUDI SET",
"broadcast.error.camera.preview.not.ready": "Anteprima camera non pronta",
"broadcast.error.permissions.camera.mic": "Permessi camera e microfono richiesti",
@@ -439,6 +441,8 @@ enum L10n {
"api.error.unauthorized": "Session expired. Please sign in again.",
"app.name": "Match Live TV",
"app.slogan": "Every match, every event, for your fans.",
"announcement.dismiss": "Dismiss notice",
"announcement.open": "Open",
"broadcast.close.set.button": "CLOSE SET",
"broadcast.error.camera.preview.not.ready": "Camera preview not ready",
"broadcast.error.permissions.camera.mic": "Camera and microphone permission required",
@@ -770,6 +774,8 @@ enum L10n {
"api.error.unauthorized": "Session expirée. Connectez-vous à nouveau.",
"app.name": "Match Live TV",
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
"announcement.dismiss": "Fermer l'alerte",
"announcement.open": "Ouvrir",
"broadcast.close.set.button": "FERMER LE SET",
"broadcast.error.camera.preview.not.ready": "Aperçu caméra non prêt",
"broadcast.error.permissions.camera.mic": "Autorisations caméra et micro requises",
@@ -1101,6 +1107,8 @@ enum L10n {
"api.error.unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.",
"app.name": "Match Live TV",
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
"announcement.dismiss": "Hinweis schließen",
"announcement.open": "Öffnen",
"broadcast.close.set.button": "SATZ BEENDEN",
"broadcast.error.camera.preview.not.ready": "Kameravorschau nicht bereit",
"broadcast.error.permissions.camera.mic": "Kamera- und Mikrofonberechtigung erforderlich",
@@ -1432,6 +1440,8 @@ enum L10n {
"api.error.unauthorized": "Sesión caducada. Vuelve a iniciar sesión.",
"app.name": "Match Live TV",
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
"announcement.dismiss": "Cerrar aviso",
"announcement.open": "Abrir",
"broadcast.close.set.button": "CERRAR SET",
"broadcast.error.camera.preview.not.ready": "Vista previa de cámara no lista",
"broadcast.error.permissions.camera.mic": "Se requieren permisos de cámara y micrófono",
@@ -109,3 +109,17 @@ final class TokenStore: ObservableObject {
SecItemDelete(query as CFDictionary)
}
}
final class AnnouncementStore {
private let key = "mltv.dismissedAnnouncements"
func dismissedIds() -> Set<String> {
Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
}
func dismiss(_ id: String) {
var ids = dismissedIds()
ids.insert(id)
UserDefaults.standard.set(Array(ids), forKey: key)
}
}
@@ -41,6 +41,17 @@ struct MessageResponse: Decodable {
let message: String
}
struct AnnouncementDto: Decodable, Identifiable, Equatable {
let id: String
let kind: String
let severity: String
let title: String
let body: String
let dismissible: Bool
let actionUrl: String?
let actionLabel: String?
}
struct LoginResponse: Decodable {
let user: UserDto
let accessToken: String
@@ -106,6 +106,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await get("sports")
}
func announcements() async throws -> [AnnouncementDto] {
try await get("announcements", query: ["platform": "ios"])
}
func teams() async throws -> [TeamDto] {
try await get("teams")
}
@@ -218,8 +222,8 @@ final class MatchLiveAPI: @unchecked Sendable {
private struct EmptyBody: Encodable {}
private func get<T: Decodable>(_ path: String) async throws -> T {
try await request(path, method: "GET")
private func get<T: Decodable>(_ path: String, query: [String: String] = [:]) async throws -> T {
try await request(path, method: "GET", query: query)
}
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
@@ -257,9 +261,17 @@ final class MatchLiveAPI: @unchecked Sendable {
private func request<T: Decodable, B: Encodable>(
_ path: String,
method: String,
body: B? = nil as EmptyBody?
body: B? = nil as EmptyBody?,
query: [String: String] = [:]
) async throws -> T {
var request = URLRequest(url: baseURL.appendingPathComponent(path))
guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else {
throw APIError.invalidURL
}
if !query.isEmpty {
components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
}
guard let url = components.url else { throw APIError.invalidURL }
var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
@@ -12,6 +12,7 @@ final class AppContainer: ObservableObject {
let scoreController: ScoreController
let wizardSession = WizardSessionHolder()
let broadcastCoordinator = LiveBroadcastCoordinator()
let announcementStore = AnnouncementStore()
init() {
let api = MatchLiveAPI()
@@ -39,4 +40,10 @@ final class AppContainer: ObservableObject {
OverlayLogoCache.configure(token: session.accessToken)
}
}
func visibleAnnouncements() async -> [AnnouncementDto] {
let dismissed = announcementStore.dismissedIds()
let all = (try? await api.announcements()) ?? []
return all.filter { !dismissed.contains($0.id) }
}
}
@@ -12,6 +12,7 @@ struct LoginScreen: View {
@State private var passwordVisible = false
@State private var showLanguagePicker = false
@State private var languageTick = 0
@State private var announcements: [AnnouncementDto] = []
var body: some View {
MatchScreenScaffold {
@@ -30,6 +31,16 @@ struct LoginScreen: View {
.padding(.top, 8)
MatchLiveWordmark(showSlogan: true)
.padding(.top, 16)
if !announcements.isEmpty {
AnnouncementBanners(
announcements: announcements,
onDismiss: { id in
container.announcementStore.dismiss(id)
announcements.removeAll { $0.id == id }
}
)
.padding(.top, 24)
}
Text(L10n.t("login.submit").uppercased())
.font(MatchTypography.headlineMedium)
.padding(.top, 32)
@@ -73,6 +84,9 @@ struct LoginScreen: View {
languageTick += 1
}
.id(languageTick)
.task {
announcements = await container.visibleAnnouncements()
}
}
private func submitLogin() {
@@ -22,6 +22,7 @@ struct MatchesScreen: View {
@State private var snackbar: String?
@State private var showLanguagePicker = false
@State private var languageTick = 0
@State private var announcements: [AnnouncementDto] = []
var body: some View {
MatchScreenScaffold(
@@ -48,6 +49,18 @@ struct MatchesScreen: View {
.padding(.vertical, 8)
},
content: {
VStack(spacing: 0) {
if !announcements.isEmpty {
AnnouncementBanners(
announcements: announcements,
onDismiss: { id in
container.announcementStore.dismiss(id)
announcements.removeAll { $0.id == id }
}
)
.padding(.horizontal, 20)
.padding(.top, 8)
}
Group {
if loading {
ProgressView().tint(MatchColors.primaryRed)
@@ -135,6 +148,7 @@ struct MatchesScreen: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
)
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
.alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
@@ -277,6 +291,7 @@ struct MatchesScreen: View {
} catch {
self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error")
}
announcements = await container.visibleAnnouncements()
loading = false
refreshing = false
}
@@ -418,6 +433,77 @@ private struct MatchListCard: View {
}
}
struct AnnouncementBanners: View {
let announcements: [AnnouncementDto]
let onDismiss: (String) -> Void
var body: some View {
VStack(spacing: 8) {
ForEach(announcements) { item in
AnnouncementBanner(item: item, onDismiss: { onDismiss(item.id) })
}
}
}
}
private struct AnnouncementBanner: View {
let item: AnnouncementDto
let onDismiss: () -> Void
@Environment(\.openURL) private var openURL
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .top, spacing: 12) {
Image(systemName: iconName)
.foregroundStyle(accent)
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(MatchTypography.titleMedium)
.foregroundStyle(accent)
Text(item.body)
.font(MatchTypography.bodyMedium)
}
Spacer(minLength: 8)
if item.dismissible {
Button(action: onDismiss) {
Image(systemName: "xmark")
.foregroundStyle(MatchColors.textSecondary)
}
.buttonStyle(.plain)
.accessibilityLabel(L10n.t("announcement.dismiss"))
}
}
if let urlString = item.actionUrl, let url = URL(string: urlString), !urlString.isEmpty {
Button(item.actionLabel?.isEmpty == false ? item.actionLabel! : L10n.t("announcement.open")) {
openURL(url)
}
.font(MatchTypography.labelLarge)
.foregroundStyle(accent)
}
}
.padding(14)
.background(accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 12))
}
private var accent: Color {
if item.severity == "critical" || item.kind == "maintenance" {
return MatchColors.accentYellow
}
if item.kind == "update" {
return MatchColors.successGreen
}
return MatchColors.primaryRed
}
private var iconName: String {
switch item.kind {
case "maintenance": return "wrench.and.screwdriver.fill"
case "update": return "arrow.down.app.fill"
default: return "megaphone.fill"
}
}
}
private struct NewMatchSheet: View {
let onSchedule: () -> Void
let onQuickStart: () -> Void