Compare commits

..
Author SHA1 Message Date
eminuxandCursor f02782d0f0 Aggiunge traduzioni it/en/fr/de/es agli avvisi e toglie gli override Android/iOS.
Un avviso ha un testo per lingua; per differenziare le piattaforme si creano due avvisi. L'app riceve già il copy risolto da Accept-Language.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:26:30 +02:00
eminuxandCursor fb82b74e92 Allinea verticalmente le righe della tabella sessioni admin.
Il flex era sulla td e spezzava vertical-align; ora sta nel contenuto e l'ingest è su una riga.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:24:18 +02:00
eminuxandCursor a07f231417 Permette titolo, testo e link diversi per Android e iOS negli avvisi.
Separa i canali app e mostra il nodo ingest nelle sessioni admin.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 16:09:04 +02:00
eminuxandCursor 43d5003de8 Completa UI e test del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:39:11 +02:00
eminuxandCursor eadfc29da3 Corregge YAML form contatti: quota form_lead con due punti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:37:45 +02:00
eminuxandCursor 50d3e2cf16 Completa traduzioni privacy, mailer e flash del form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:52 +02:00
eminuxandCursor 55e71ca838 Allinea privacy DE/ES/FR al form contatti.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 15:31:07 +02:00
eminuxandCursor 5b723bf844 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>
2026-08-20 15:30:49 +02:00
eminuxandCursor e502844b88 fix: non bloccare go-live se agent slate Hetzner è down
Se il push della cover custom all'agent fallisce (timeout/connection refused),
usa lo slate di default invece di abortire la creazione sessione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 13:26:52 +02:00
eminuxandCursor 0702cb699c Rendi affidabile pausa con slate e conferma i controlli laterali.
Evita buchi HLS/YouTube in pausa (path slate ricreata, reload player, no patch recording inutili) e richiede conferma su tasti laterali app/regia.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 12:44:23 +02:00
eminuxandCursor e09b3ffcf8 Corregge validazione upload copertina e generazione slate al go-live.
Rifiuta file troppo grandi o non-immagine via Marcel prima di attach, e genera la slate in sync anche quando cover_image esiste ma cover_slate non è ancora pronta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-20 09:51:33 +02:00
eminuxandCursor 41d4235258 Aggiunge copertina sponsor custom solo Premium Full, con override in wizard e slate sui nodi di streaming.
Permette upload da portale e app con ereditarietà partita→squadra→società, generazione MP4 via ffmpeg e distribuzione su home lab e nodi CPX per pause e assenza segnale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 23:47:17 +02:00
eminuxandCursor a51d5e8da5 Non inviare in automatico le istruzioni di bonifico.
IBAN e causale restano da comunicare a mano dall'admin; la richiesta registra solo l'ordine in attesa.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:40:45 +02:00
eminuxandCursor 5bcb4170c7 Mostra il piano attivo dopo il bonifico e allinea il flusso società.
Dopo la conferma admin la card restava su «Paga con bonifico»; la società parte da Free, l'owner è staff trasmissione e le mail non bloccano se manca SMTP.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-19 18:31:39 +02:00
179 changed files with 6194 additions and 235 deletions
@@ -0,0 +1,76 @@
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_android: true,
show_on_ios: 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_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
translations: {
en: AppAnnouncement::COPY_KEYS,
fr: AppAnnouncement::COPY_KEYS,
de: AppAnnouncement::COPY_KEYS,
es: AppAnnouncement::COPY_KEYS
}
).tap do |permitted|
%i[dismissible show_on_android show_on_ios 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
@@ -5,7 +5,7 @@ module Admin
@host = HostMetrics.new.sample! @host = HostMetrics.new.sample!
@active_sessions = StreamSession @active_sessions = StreamSession
.where(status: DashboardStats::ACTIVE_STATUSES) .where(status: DashboardStats::ACTIVE_STATUSES)
.includes(match: :team) .includes(:stream_node, match: :team)
.order(started_at: :desc) .order(started_at: :desc)
@teams = Team.includes(:matches).order(:name).limit(8) @teams = Team.includes(:matches).order(:name).limit(8)
end end
@@ -1,11 +1,11 @@
module Admin module Admin
class SessionsController < Admin::BaseController class SessionsController < Admin::BaseController
def index def index
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50) @sessions = StreamSession.includes(:stream_node, :user, match: :team).order(created_at: :desc).limit(50)
end end
def show def show
@session = StreamSession.find(params[:id]) @session = StreamSession.includes(:stream_node, match: :team).find(params[:id])
@events = @session.stream_events.recent.limit(50) @events = @session.stream_events.recent.limit(50)
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_platform(platform).newest
render json: announcements.map { |item| item.as_api_json(platform: platform) }
end
end
end
end
@@ -2,6 +2,7 @@ module Api
module V1 module V1
class BaseController < ApplicationController class BaseController < ApplicationController
rescue_from Teams::EntitlementError, with: :render_entitlement_error rescue_from Teams::EntitlementError, with: :render_entitlement_error
rescue_from BrandingAttachments::CoverUploadError, with: :render_cover_upload_error
rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error rescue_from Youtube::BroadcastService::Error, with: :render_youtube_error
private private
@@ -20,6 +21,10 @@ module Api
billing_url: error.billing_url billing_url: error.billing_url
}, status: :forbidden }, status: :forbidden
end end
def render_cover_upload_error(error)
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
end
end end
end end
end end
@@ -2,6 +2,7 @@ module Api
module V1 module V1
class MatchesController < BaseController class MatchesController < BaseController
include Api::UrlHelper include Api::UrlHelper
include Api::CoverJson
include BrandingAttachments include BrandingAttachments
before_action :set_team, only: %i[index create] before_action :set_team, only: %i[index create]
@@ -21,6 +22,7 @@ module Api
normalize_scoring_rules!(attrs) normalize_scoring_rules!(attrs)
match = @team.matches.create!(attrs) match = @team.matches.create!(attrs)
attach_opponent_logo(match) attach_opponent_logo(match)
attach_match_cover(match)
render json: match_json(match), status: :created render json: match_json(match), status: :created
end end
@@ -34,6 +36,7 @@ module Api
normalize_opponent_color!(attrs) normalize_opponent_color!(attrs)
@match.update!(attrs) @match.update!(attrs)
attach_opponent_logo(@match) attach_opponent_logo(@match)
attach_match_cover(@match)
render json: match_json(@match) render json: match_json(@match)
end end
@@ -98,6 +101,11 @@ module Api
match.opponent_logo_file.attach(file) if file.present? match.opponent_logo_file.attach(file) if file.present?
end end
def attach_match_cover(match)
attach_cover_image(match, entitlements: match.team.entitlements)
enqueue_cover_slate_generation(match)
end
def match_json(match, detail: false) def match_json(match, detail: false)
active = active_session_for(match) active = active_session_for(match)
team = match.team team = match.team
@@ -123,6 +131,7 @@ module Api
home_logo_url: api_absolute_url(team.effective_logo_url), home_logo_url: api_absolute_url(team.effective_logo_url),
opponent_primary_color: match.effective_opponent_primary_color, opponent_primary_color: match.effective_opponent_primary_color,
opponent_logo_url: api_absolute_url(match.opponent_logo_url), opponent_logo_url: api_absolute_url(match.opponent_logo_url),
**match_cover_json(match),
active_session_id: active&.id, active_session_id: active&.id,
active_session_status: active&.status, active_session_status: active&.status,
stream_completed: match.stream_completed?, stream_completed: match.stream_completed?,
@@ -137,7 +137,8 @@ module Api
youtube_mode: ent.plan.youtube_mode, youtube_mode: ent.plan.youtube_mode,
staff_role: current_user.staff_role_for(team), staff_role: current_user.staff_role_for(team),
can_stream: current_user.can_stream_for?(team), can_stream: current_user.can_stream_for?(team),
can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled? can_connect_youtube: team.club.owned_by?(current_user) && ent.premium_full? && ent.youtube_enabled?,
custom_cover_enabled: ent.can_use_custom_cover?
} }
if detail if detail
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
@@ -0,0 +1,16 @@
module Api
module CoverJson
extend ActiveSupport::Concern
private
def match_cover_json(match)
resolver = Streams::CoverResolver.new(match)
{
effective_cover_url: api_absolute_url(resolver.effective_cover_url),
cover_source: resolver.cover_source,
custom_cover_enabled: match.team.entitlements.can_use_custom_cover?
}
end
end
end
@@ -8,6 +8,48 @@ module BrandingAttachments
record.logo_file.attach(file) if file.present? record.logo_file.attach(file) if file.present?
end end
def attach_cover_image(record, entitlements:)
remove_flag = params[:remove_cover].to_s == "1" || params.dig(:match, :remove_cover).to_s == "1"
if remove_flag
record.cover_image.purge if record.cover_image.attached?
record.cover_slate.purge if record.cover_slate.attached?
return
end
file = params.dig(:branding, :cover_image) || params[:cover_image]
return if file.blank?
entitlements.assert_can_upload_cover!
validate_cover_file!(file)
record.cover_slate.purge if record.cover_slate.attached?
record.cover_image.attach(file)
end
def validate_cover_file!(file)
allowed_types = Coverable::COVER_IMAGE_TYPES
# Rileva MIME dal contenuto (magic bytes) ignorando il declared_type del client.
io = file.respond_to?(:tempfile) ? file.tempfile : file
io.rewind if io.respond_to?(:rewind)
detected = Marcel::MimeType.for(io, name: file.respond_to?(:original_filename) ? file.original_filename : nil)
io.rewind if io.respond_to?(:rewind)
unless detected.in?(allowed_types)
raise CoverUploadError, I18n.t("coverable.errors.invalid_type")
end
byte_size = file.respond_to?(:size) ? file.size : File.size(file.path)
if byte_size > Coverable::MAX_COVER_BYTES
raise CoverUploadError, I18n.t("coverable.errors.too_large")
end
end
class CoverUploadError < StandardError; end
def enqueue_cover_slate_generation(record)
GenerateCoverSlateJob.enqueue_for(record) if record.cover_image.attached?
end
def normalize_hex_color(value, fallback) def normalize_hex_color(value, fallback)
v = value.to_s.strip v = value.to_s.strip
v = "##{v}" if v.match?(/\A[0-9A-Fa-f]{6}\z/) v = "##{v}" if v.match?(/\A[0-9A-Fa-f]{6}\z/)
@@ -23,23 +23,28 @@ module Public
ClubMembership.create!(user: current_user, club: club, role: "owner") ClubMembership.create!(user: current_user, club: club, role: "owner")
first_team_name = params.dig(:first_team, :name).presence || t("club.new.default_first_team_name") first_team_name = params.dig(:first_team, :name).presence || t("club.new.default_first_team_name")
club.teams.create!( first_team = club.teams.create!(
name: first_team_name, name: first_team_name,
sport: club.sport sport: club.sport
) )
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free" desired_plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
Billing::AssignPlan.call(club: club, plan_slug: plan) Billing::AssignPlan.call(club: club, plan_slug: "free")
Teams::StaffAssignment.designate_club_owner!(team: first_team, user: current_user)
if plan.in?(%w[premium_light premium_full]) && MatchLiveTv.stripe_enabled? if desired_plan.in?(%w[premium_light premium_full])
interval = Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if MatchLiveTv.stripe_enabled?
unless club.billing_profile_complete? unless club.billing_profile_complete?
redirect_to public_club_billing_profile_path(club, plan: plan, interval: checkout_interval_param), redirect_to public_club_billing_profile_path(club, plan: desired_plan, interval: interval),
alert: t("flash.clubs.complete_billing_first") alert: t("flash.clubs.complete_billing_first")
return return
end end
interval = checkout_interval_param redirect_to public_club_checkout_path(club, plan: desired_plan, interval: interval)
redirect_to public_club_checkout_path(club, plan: plan, interval: interval) else
redirect_to public_club_billing_path(club), notice: t("flash.clubs.created_complete_subscription")
end
else else
redirect_to public_club_path(club), notice: t("flash.clubs.created") redirect_to public_club_path(club), notice: t("flash.clubs.created")
end end
@@ -59,17 +64,26 @@ module Public
def edit def edit
require_club_owner!(@club) require_club_owner!(@club)
@entitlements = @club.teams.first&.entitlements
end end
def update def update
require_club_owner!(@club) require_club_owner!(@club)
@entitlements = @club.teams.first!.entitlements
@club.assign_attributes(club_params) @club.assign_attributes(club_params)
attach_branding_logo(@club) attach_branding_logo(@club)
attach_cover_image(@club, entitlements: @entitlements)
@club.save! @club.save!
enqueue_cover_slate_generation(@club)
redirect_to public_club_path(@club), notice: t("flash.clubs.updated") redirect_to public_club_path(@club), notice: t("flash.clubs.updated")
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@entitlements = @club.teams.first&.entitlements
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
rescue Teams::EntitlementError, BrandingAttachments::CoverUploadError => e
@entitlements = @club.teams.first&.entitlements
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end end
def billing def billing
@@ -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
@@ -51,6 +51,7 @@ module Public
@session.status.in?(%w[connecting live reconnecting paused]) @session.status.in?(%w[connecting live reconnecting paused])
) )
@publisher_online = !@stream_closed && mediamtx_publisher_online?(@session) @publisher_online = !@stream_closed && mediamtx_publisher_online?(@session)
@cover_poster_url = Streams::CoverResolver.new(@match).effective_cover_url
end end
def status def status
@@ -9,6 +9,7 @@ module Public
protect_from_forgery with: :exception protect_from_forgery with: :exception
before_action :set_site_locale before_action :set_site_locale
before_action :load_site_announcements
private private
@@ -40,5 +41,27 @@ module Public
def logged_in? def logged_in?
current_user.present? current_user.present?
end 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
end end
@@ -8,6 +8,7 @@ module Public
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" }, { loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" }, { loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" }, { 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}/live", changefreq: "hourly", priority: "0.85" },
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" }, { loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" }, { loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
@@ -19,6 +19,7 @@ module Public
require_club_owner!(@club) require_club_owner!(@club)
team = @club.teams.create!(team_params) team = @club.teams.create!(team_params)
attach_branding_logo(team) attach_branding_logo(team)
Teams::StaffAssignment.designate_club_owner!(team: team, user: current_user)
redirect_to public_club_path(@club), notice: t("flash.teams.added", name: team.name) redirect_to public_club_path(@club), notice: t("flash.teams.added", name: team.name)
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
@@ -41,19 +42,28 @@ module Public
def edit def edit
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
@club = @team.club @club = @team.club
@entitlements = @team.entitlements
end end
def update def update
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
@team.assign_attributes(team_params) @team.assign_attributes(team_params)
attach_branding_logo(@team) attach_branding_logo(@team)
attach_cover_image(@team, entitlements: @team.entitlements)
attach_team_photo(@team) attach_team_photo(@team)
@team.save! @team.save!
enqueue_cover_slate_generation(@team)
redirect_to public_team_details_path(@team), notice: t("flash.teams.updated") redirect_to public_team_details_path(@team), notice: t("flash.teams.updated")
rescue ActiveRecord::RecordInvalid => e rescue ActiveRecord::RecordInvalid => e
@club = @team.club @club = @team.club
@entitlements = @team.entitlements
flash.now[:alert] = e.record.errors.full_messages.join(", ") flash.now[:alert] = e.record.errors.full_messages.join(", ")
render :edit, status: :unprocessable_entity render :edit, status: :unprocessable_entity
rescue Teams::EntitlementError, BrandingAttachments::CoverUploadError => e
@club = @team.club
@entitlements = @team.entitlements
flash.now[:alert] = e.message
render :edit, status: :unprocessable_entity
end end
def invite def invite
+40
View File
@@ -30,6 +30,19 @@ module AdminHelper
links links
end end
def admin_session_ingest_badge_class(node)
case node.role
when "home" then "badge--ingest-home"
when "lab" then "badge--ingest-lab"
when "cloud" then "badge--ingest-cloud"
else "badge--paused"
end
end
def admin_session_ingest_role_label(node)
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
end
def admin_regia_expires_label(iso_time) def admin_regia_expires_label(iso_time)
return if iso_time.blank? return if iso_time.blank?
@@ -37,4 +50,31 @@ module AdminHelper
rescue ArgumentError, TypeError rescue ArgumentError, TypeError
nil nil
end 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 end
+1 -1
View File
@@ -1,6 +1,6 @@
module LegalHelper module LegalHelper
def legal_last_updated def legal_last_updated
"3 giugno 2026" "20 agosto 2026"
end end
def cookie_policy_last_updated def cookie_policy_last_updated
@@ -11,6 +11,13 @@ module Public
quote = club&.active_billing_quote quote = club&.active_billing_quote
if quote if quote
if quote.plan_slug == target_plan.slug if quote.plan_slug == target_plan.slug
if current_paid_plan?(subscription, target_plan)
return {
kind: :current,
label: "#{I18n.t('billing.actions.current_plan')}#{quote.price_label}"
}
end
return { kind: :quoted, plan: target_plan, quote: quote, intervals: [quote.billing_interval] } return { kind: :quoted, plan: target_plan, quote: quote, intervals: [quote.billing_interval] }
end end
@@ -19,6 +26,9 @@ module Public
intervals = bank_transfer_intervals_for(target_plan) intervals = bank_transfer_intervals_for(target_plan)
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if MatchLiveTv.bank_transfer_configured? if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals } return { kind: :bank_only, plan: target_plan, intervals: intervals }
end end
@@ -28,6 +38,9 @@ module Public
stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug) stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
if stripe_intervals.empty? if stripe_intervals.empty?
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if MatchLiveTv.bank_transfer_configured? if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals } return { kind: :bank_only, plan: target_plan, intervals: intervals }
end end
@@ -54,6 +67,10 @@ module Public
} }
end end
if current_paid_plan?(subscription, target_plan)
return { kind: :current, label: current_plan_label(target_plan, subscription) }
end
if current_slug == "free" || !stripe_subscription_active if current_slug == "free" || !stripe_subscription_active
{ kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription } { kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription }
else else
@@ -136,8 +153,21 @@ module Public
return false unless MatchLiveTv.bank_transfer_configured? return false unless MatchLiveTv.bank_transfer_configured?
return false if quote && !quote.matches?(plan.slug, interval) return false if quote && !quote.matches?(plan.slug, interval)
return false if pending_transfer&.awaiting_payment? return false if pending_transfer&.awaiting_payment?
return false if current_paid_plan?(club.subscription, plan)
true true
end end
def current_paid_plan?(subscription, target_plan)
return false unless subscription&.active? && subscription.premium?
return false if subscription.plan_change_pending?
subscription.plan.slug == target_plan.slug
end
def current_plan_label(target_plan, subscription)
interval = subscription&.billing_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
"#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: interval)}"
end
end end
end end
@@ -52,6 +52,18 @@ module Public
muteError: t("regia.js.mute_error"), muteError: t("regia.js.mute_error"),
closeError: t("regia.js.close_error"), closeError: t("regia.js.close_error"),
closeConfirm: t("regia.js.close_confirm"), closeConfirm: t("regia.js.close_confirm"),
pauseConfirmTitle: t("regia.js.pause_confirm_title"),
pauseConfirmBody: t("regia.js.pause_confirm_body"),
resumeConfirmTitle: t("regia.js.resume_confirm_title"),
resumeConfirmBody: t("regia.js.resume_confirm_body"),
muteConfirmTitle: t("regia.js.mute_confirm_title"),
muteConfirmBody: t("regia.js.mute_confirm_body"),
unmuteConfirmTitle: t("regia.js.unmute_confirm_title"),
unmuteConfirmBody: t("regia.js.unmute_confirm_body"),
advancePeriodTitle: t("regia.js.advance_period_title"),
advancePeriodBody: t("regia.js.advance_period_body"),
confirmAction: t("regia.modal.confirm_action"),
stopConfirmTitle: t("regia.js.stop_confirm_title"),
linkUnavailable: t("regia.js.link_unavailable"), linkUnavailable: t("regia.js.link_unavailable"),
linkCopied: t("regia.js.link_copied"), linkCopied: t("regia.js.link_copied"),
copyPrompt: t("regia.js.copy_prompt"), copyPrompt: t("regia.js.copy_prompt"),
@@ -0,0 +1,19 @@
class GenerateCoverSlateJob < ApplicationJob
queue_as :default
def self.enqueue_for(record)
return unless record&.cover_image&.attached?
queue = record.is_a?(Match) ? :critical : :default
set(queue: queue).perform_later(record.class.name, record.id)
end
def perform(class_name, record_id)
record = class_name.constantize.find_by(id: record_id)
return unless record&.cover_image&.attached?
Streams::GenerateCoverSlate.call(record)
rescue Streams::GenerateCoverSlate::Error => e
Rails.logger.warn("[GenerateCoverSlateJob] #{class_name}##{record_id} #{e.message}")
end
end
+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 include PasswordComplexity
has_secure_password 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 validates :username, presence: true, uniqueness: true
end end
+240
View File
@@ -0,0 +1,240 @@
class AppAnnouncement < ApplicationRecord
KINDS = %w[info maintenance update].freeze
SEVERITIES = %w[info warning critical].freeze
STATUSES = %w[draft published archived].freeze
CHANNELS = {
android: :show_on_android,
ios: :show_on_ios,
web_public: :show_on_web_public,
web_private: :show_on_web_private
}.freeze
COPY_KEYS = %w[title body action_url action_label].freeze
URL_KEYS = %w[action_url].freeze
TITLE_KEYS = %w[title].freeze
BODY_KEYS = %w[body].freeze
LABEL_KEYS = %w[action_label].freeze
LOCALE_FALLBACKS = {
"it" => %w[it],
"en" => %w[en it],
"fr" => %w[fr en it],
"de" => %w[de en it],
"es" => %w[es en it]
}.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 }, allow_blank: true
validates :body, length: { maximum: 2000 }, allow_blank: true
validates :action_label, length: { maximum: 40 }, allow_blank: true
URL_KEYS.each do |field|
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
end
validate :ends_at_after_starts_at
validate :at_least_one_channel
validate :translations_are_valid
before_validation :normalize_translations
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
active_now.where("show_on_android = TRUE OR show_on_ios = TRUE")
end
def self.for_platform(platform)
case platform.to_s
when "android" then for_channel(:android)
when "ios" then for_channel(:ios)
else for_app
end
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 copy_values_for(locale)
loc = locale.to_s
if default_locale?(loc)
COPY_KEYS.index_with { |key| public_send(key).to_s.presence }
else
translation_hash(loc)
end
end
def filled_locale_codes
LocaleResolver.available.select { |loc| locale_has_copy?(loc) }.map(&:to_s)
end
def as_api_json(platform: nil, locale: I18n.locale)
{
id: id,
kind: kind,
severity: severity,
title: copy_for("title", locale: locale),
body: copy_for("body", locale: locale),
dismissible: dismissible,
action_url: resolved_action_url(platform, locale: locale),
action_label: copy_for("action_label", locale: locale).presence,
starts_at: starts_at&.iso8601,
ends_at: ends_at&.iso8601
}
end
def localized_title(locale: I18n.locale)
copy_for("title", locale: locale)
end
def localized_body(locale: I18n.locale)
copy_for("body", locale: locale)
end
def localized_action_label(locale: I18n.locale)
copy_for("action_label", locale: locale)
end
def resolved_action_url(platform, locale: I18n.locale)
url = copy_for("action_url", locale: locale)
return url if 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 copy_for(field, locale: I18n.locale)
field = field.to_s
locale_chain(locale).each do |loc|
value = value_for(loc, field)
return value if value.present?
end
nil
end
def value_for(locale, key)
copy_values_for(locale)[key].presence
end
def translation_hash(locale)
raw = translations.is_a?(Hash) ? translations : {}
values = raw.stringify_keys[locale.to_s]
return {} unless values.is_a?(Hash)
values.stringify_keys
end
def locale_chain(locale)
loc = (LocaleResolver.normalize(locale) || I18n.default_locale).to_s
LOCALE_FALLBACKS[loc] || [loc, I18n.default_locale.to_s].uniq
end
def locale_has_copy?(locale)
values = copy_values_for(locale)
values["title"].present? || values["body"].present?
end
def default_locale?(locale)
locale.to_s == I18n.default_locale.to_s
end
def normalize_translations
self.translations = self.class.sanitize_translations(translations)
end
def self.sanitize_translations(value)
raw = coerce_translation_hash(value)
LocaleResolver.available.each_with_object({}) do |loc, acc|
next if loc.to_s == I18n.default_locale.to_s
payload = raw[loc.to_s]
next unless payload.is_a?(Hash)
cleaned = COPY_KEYS.each_with_object({}) do |key, fields|
text = payload[key].to_s.strip
fields[key] = text if text.present?
end
acc[loc.to_s] = cleaned if cleaned.any?
end
end
def self.coerce_translation_hash(value)
hash = if value.is_a?(ActionController::Parameters)
value.to_unsafe_h
elsif value.is_a?(Hash)
value
else
{}
end
hash.deep_stringify_keys
end
private_class_method :coerce_translation_hash
def at_least_one_channel
return if show_on_android? || show_on_ios? || 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
def translations_are_valid
translation_entries.each do |locale, payload|
TITLE_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 120
end
BODY_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 2000
end
LABEL_KEYS.each do |key|
errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 40
end
URL_KEYS.each do |key|
url = payload[key].to_s
next if url.blank? || url.match?(/\Ahttps?:\/\/.+/i)
errors.add(:base, :translation_invalid_url, locale: locale)
end
end
end
def translation_entries
raw = translations.is_a?(Hash) ? translations : {}
raw.stringify_keys.filter_map do |locale, payload|
next unless payload.is_a?(Hash)
[locale, payload.stringify_keys]
end
end
end
+1
View File
@@ -1,5 +1,6 @@
class Club < ApplicationRecord class Club < ApplicationRecord
include Brandable include Brandable
include Coverable
include ClubBillingProfile include ClubBillingProfile
has_many :club_memberships, dependent: :destroy has_many :club_memberships, dependent: :destroy
+50
View File
@@ -0,0 +1,50 @@
module Coverable
extend ActiveSupport::Concern
COVER_IMAGE_TYPES = %w[image/png image/jpeg image/webp].freeze
MAX_COVER_BYTES = 5.megabytes
included do
has_one_attached :cover_image
has_one_attached :cover_slate
validate :cover_image_file_type, if: -> { cover_image.attached? }
validate :cover_image_file_size, if: -> { cover_image.attached? }
end
def cover_image_attached?
cover_image.attached?
end
def cover_url
return unless cover_image.attached?
Rails.application.routes.url_helpers.rails_blob_path(cover_image, only_path: true)
end
def effective_cover_url
if cover_image.attached?
cover_url
else
cover_parent&.effective_cover_url
end
end
private
def cover_parent
nil
end
def cover_image_file_type
return if cover_image.content_type.in?(COVER_IMAGE_TYPES)
errors.add(:cover_image, I18n.t("coverable.errors.invalid_type"))
end
def cover_image_file_size
return if cover_image.byte_size <= MAX_COVER_BYTES
errors.add(:cover_image, I18n.t("coverable.errors.too_large"))
end
end
+6
View File
@@ -1,4 +1,6 @@
class Match < ApplicationRecord class Match < ApplicationRecord
include Coverable
belongs_to :team belongs_to :team
has_many :stream_sessions, dependent: :destroy has_many :stream_sessions, dependent: :destroy
@@ -174,4 +176,8 @@ class Match < ApplicationRecord
rescue ArgumentError => e rescue ArgumentError => e
errors.add(:scoring_rules, e.message) errors.add(:scoring_rules, e.message)
end end
def cover_parent
nil
end
end end
+4
View File
@@ -84,6 +84,10 @@ class Plan < ApplicationRecord
feature("youtube_enabled") == true feature("youtube_enabled") == true
end end
def custom_cover_enabled?
feature("custom_cover") == true
end
def premium? def premium?
slug.in?(PREMIUM_SLUGS) slug.in?(PREMIUM_SLUGS)
end end
+5
View File
@@ -1,5 +1,6 @@
class Team < ApplicationRecord class Team < ApplicationRecord
include Brandable include Brandable
include Coverable
belongs_to :club belongs_to :club
has_many :user_teams, dependent: :destroy has_many :user_teams, dependent: :destroy
@@ -99,6 +100,10 @@ class Team < ApplicationRecord
club club
end end
def cover_parent
club
end
def assign_slug def assign_slug
self.slug = Teams::GenerateSlug.call(self) if slug.blank? self.slug = Teams::GenerateSlug.call(self) if slug.blank?
end end
@@ -88,7 +88,7 @@ module Billing
mailer_action: :plan_activated_with_invoice mailer_action: :plan_activated_with_invoice
) )
else else
BankTransferMailer.with(order: @order.reload).plan_activated.deliver_now MatchLiveTv.deliver_mail(BankTransferMailer.with(order: @order.reload).plan_activated)
end end
end end
@@ -26,8 +26,10 @@ module Billing
@invoice.update!(status: "issued") @invoice.update!(status: "issued")
Billing::InvoiceMailer.with(invoice: @invoice).public_send(@mailer_action).deliver_now mail = Billing::InvoiceMailer.with(invoice: @invoice).public_send(@mailer_action)
if MatchLiveTv.deliver_mail(mail)
@invoice.update!(status: "sent", emailed_at: Time.current) @invoice.update!(status: "sent", emailed_at: Time.current)
end
@invoice @invoice
end end
@@ -28,7 +28,6 @@ module Billing
existing = @club.billing_transfer_orders.awaiting_payment.first existing = @club.billing_transfer_orders.awaiting_payment.first
if existing if existing
if existing.plan_slug == @plan_slug && existing.billing_interval == @interval && existing.amount_cents == amount_cents if existing.plan_slug == @plan_slug && existing.billing_interval == @interval && existing.amount_cents == amount_cents
BankTransferMailer.with(order: existing).instructions.deliver_now
return existing return existing
end end
@@ -59,7 +58,8 @@ module Billing
) )
end end
BankTransferMailer.with(order: order).instructions.deliver_now # Le istruzioni (IBAN/causale) le manda a mano l'admin da /admin/billing.
Rails.logger.info("[BankTransfer] ordine #{order.reference_code} in attesa, nessuna mail istruzioni")
order order
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
+50 -3
View File
@@ -28,7 +28,7 @@ module Mediamtx
overridePublisher: true overridePublisher: true
) )
body[:alwaysAvailable] = true body[:alwaysAvailable] = true
body[:alwaysAvailableFile] = slate_file_path body[:alwaysAvailableFile] = slate_file_path(session)
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq. # YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body) response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
unless response.success? unless response.success?
@@ -74,6 +74,47 @@ module Mediamtx
set_always_available(session, enabled: true) set_always_available(session, enabled: true)
end end
# Garantisce path config + slate: se MediaMTX ha perso la config (restart → all_others),
# in pausa non resterebbe nessuno stream verso HLS/YouTube.
def ensure_path_with_slate!(session)
path = session.mediamtx_path_name
response = @conn.get("/v3/config/paths/get/#{CGI.escape(path)}")
missing = response.status == 404 ||
(response.body.is_a?(Hash) && response.body["error"].present?)
if missing
forget_always_available(path)
begin
create_path(session)
rescue Error
# Path creato in parallelo o già presente: forza solo la slate.
forget_always_available(path)
set_always_available(session, enabled: true)
end
return true
end
conf = response.body.is_a?(Hash) ? response.body : {}
desired = slate_file_path(session)
if conf["alwaysAvailable"] == true && conf["alwaysAvailableFile"].to_s == desired.to_s
remember_always_available(path, enabled: true)
return true
end
forget_always_available(path)
set_always_available(session, enabled: true)
end
# Spegnere record solo se attivo: ogni PATCH path ricarica MediaMTX e distrugge i muxer HLS.
def disable_recording_if_active!(session)
path = session.mediamtx_path_name
response = @conn.get("/v3/config/paths/get/#{CGI.escape(path)}")
return true unless response.success?
return true unless response.body.is_a?(Hash) && response.body["record"] == true
set_path_recording(session, enabled: false)
end
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline. # Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
# Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente). # Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
def set_always_available(session, enabled:) def set_always_available(session, enabled:)
@@ -81,7 +122,7 @@ module Mediamtx
return true if always_available_remembered?(path, enabled: enabled) return true if always_available_remembered?(path, enabled: enabled)
body = if enabled body = if enabled
{ alwaysAvailable: true, alwaysAvailableFile: slate_file_path } { alwaysAvailable: true, alwaysAvailableFile: slate_file_path(session) }
else else
{ alwaysAvailable: false } { alwaysAvailable: false }
end end
@@ -173,7 +214,13 @@ module Mediamtx
SCRIPT SCRIPT
end end
def slate_file_path def slate_file_path(session)
return default_slate_file_path if session.blank?
Streams::SlateDistributor.slate_path_for_session(session)
end
def default_slate_file_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4") ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end end
@@ -79,9 +79,8 @@ module Mediamtx
end end
def restore_slate_path!(session) def restore_slate_path!(session)
return if session.platform == "matchlivetv" # Anche su matchlivetv: dopo restart MediaMTX la path può cadere su all_others senza slate.
Client.for_session(session).ensure_path_with_slate!(session)
Client.for_session(session).set_always_available(session, enabled: true)
rescue Client::Error => e rescue Client::Error => e
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}") Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
end end
@@ -146,6 +145,14 @@ module Mediamtx
nil nil
end end
def self.remember_recording_off!(session_id)
redis = Redis.new(url: ENV.fetch("REDIS_URL", "redis://localhost:6379/0"))
redis.set("mediamtx:recording:#{session_id}", "0", ex: 48.hours.to_i)
redis.set(format("mediamtx:recording:patched_at:%s", session_id), Time.current.to_i, ex: 48.hours.to_i)
rescue Redis::BaseError
nil
end
def recording_state_key(session_id) def recording_state_key(session_id)
"mediamtx:recording:#{session_id}" "mediamtx:recording:#{session_id}"
end end
+2
View File
@@ -37,6 +37,8 @@ module Sessions
session.stream_node = Streams::NodeRegistry.allocate! session.stream_node = Streams::NodeRegistry.allocate!
session.save! session.save!
Scoring::Engine.ensure_score_for(session) Scoring::Engine.ensure_score_for(session)
Streams::CoverSlateEnsurer.ensure_for!(@match)
Streams::SlateDistributor.ensure_for!(session)
Mediamtx::Client.for_session(session).create_path(session) Mediamtx::Client.for_session(session).create_path(session)
log_event( log_event(
session, session,
+5 -3
View File
@@ -7,11 +7,13 @@ module Sessions
def call def call
cancel_timeout_job cancel_timeout_job
@session.pause! if @session.may_pause? @session.pause! if @session.may_pause?
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
begin begin
mtx = Mediamtx::Client.for_session(@session) mtx = Mediamtx::Client.for_session(@session)
mtx.set_path_recording(@session, enabled: false) # Ricrea/riallinea slate prima di spegnere l'RTMP del telefono.
mtx.set_always_available(@session, enabled: true) mtx.ensure_path_with_slate!(@session)
# PATCH recording solo se era attivo (evita kill muxer HLS a ogni pausa).
mtx.disable_recording_if_active!(@session)
Mediamtx::PublisherSync.remember_recording_off!(@session.id)
rescue Mediamtx::Client::Error => e rescue Mediamtx::Client::Error => e
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}") Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
end end
@@ -0,0 +1,77 @@
module Streams
# Catena effective cover: partita → squadra → società → default Match Live TV.
class CoverResolver
Result = Struct.new(:record, :source, :cover_url, :slate_local_path, keyword_init: true)
DEFAULT_COVER_URL = "/images/copertina-canale.png".freeze
def initialize(match)
@match = match
@team = match.team
@club = @team.club
@entitlements = @team.entitlements
end
def effective_cover_url
resolve&.cover_url || default_cover_url
end
def cover_source
resolve&.source || "default"
end
def resolve
return nil unless @entitlements.can_use_custom_cover?
candidates.each do |record, source|
next unless record.cover_image.attached?
next unless record.cover_slate.attached?
path = CoverSlatePaths.local_path_for(record)
next if path.blank?
return Result.new(
record: record,
source: source,
cover_url: blob_path(record.cover_image),
slate_local_path: path
)
end
nil
end
# Restituisce il record con cover_image nella catena anche se la slate non è ancora su disco.
# Usato da CoverSlateEnsurer per sapere cosa generare al go-live.
def pending_cover_record
return nil unless @entitlements.can_use_custom_cover?
candidates.each do |record, _source|
return record if record.cover_image.attached?
end
nil
end
def default_cover_url
DEFAULT_COVER_URL
end
def default_slate_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end
private
def candidates
[
[@match, "match"],
[@team, "team"],
[@club, "club"]
]
end
def blob_path(attachment)
Rails.application.routes.url_helpers.rails_blob_path(attachment, only_path: true)
end
end
end
@@ -0,0 +1,39 @@
module Streams
# Garantisce che la slate MP4 esista per il record effettivo della partita (sync al go-live).
class CoverSlateEnsurer
def self.ensure_for!(match)
new(match).ensure!
end
def initialize(match)
@match = match
@resolver = CoverResolver.new(match)
end
def ensure!
record = find_pending_cover_record
return unless record
return if slate_ready?(record)
GenerateCoverSlate.call(record)
rescue GenerateCoverSlate::Error => e
Rails.logger.warn("[CoverSlateEnsurer] match=#{@match.id} #{e.message}")
nil
end
private
# Cerca il record con cover_image nella catena di ereditarietà,
# anche se la slate non è ancora pronta (a differenza di resolve che richiede la slate su disco).
def find_pending_cover_record
@resolver.pending_cover_record
end
def slate_ready?(record)
return false unless record.cover_slate.attached?
path = CoverSlatePaths.local_path_for(record)
path.present? && File.exist?(path)
end
end
end
@@ -0,0 +1,35 @@
module Streams
class CoverSlatePaths
def self.slates_custom_root
ENV.fetch("MEDIAMTX_SLATES_CUSTOM_DIR", "/slates/custom")
end
# ActiveStorage checksum is base64 e può contenere "/", "+", "=" — non usabile come path.
def self.safe_digest(checksum)
checksum.to_s.tr("+/", "-_").delete("=")
end
def self.filename_for(record)
return nil unless record.cover_slate.attached?
digest = safe_digest(record.cover_slate.blob.checksum)
prefix = record.class.name.underscore
"#{prefix}-#{record.id}-#{digest}.mp4"
end
def self.expected_path(record)
name = filename_for(record)
return nil if name.blank?
File.join(slates_custom_root, name)
end
def self.local_path_for(record)
path = expected_path(record)
return nil if path.blank?
return path if File.exist?(path)
nil
end
end
end
@@ -0,0 +1,121 @@
require "open3"
module Streams
# Converte cover_image in MP4 slate (H.264 720p + AAC mono), allineato a infra/scripts/generate_slate.sh.
class GenerateCoverSlate
class Error < StandardError; end
SLATE_DURATION_SECS = 30
VIDEO_FILTER = "scale=1280:720:force_original_aspect_ratio=decrease,pad=1280:720:(ow-iw)/2:(oh-ih)/2:color=0x0a0a0e".freeze
def self.call(record)
new(record).call
end
def initialize(record)
@record = record
end
def call
return @record unless @record.cover_image.attached?
@image_temp = download_cover_image
@mp4_temp = encode_slate(@image_temp)
attach_slate(@mp4_temp)
write_to_slates_dir(@mp4_temp)
@record
rescue Error
raise
rescue StandardError => e
raise Error, e.message
ensure
cleanup_tempfiles
end
private
def download_cover_image
ext = extension_for(@record.cover_image.content_type)
path = temp_path("cover-in", ext)
@record.cover_image.blob.open(tmpdir: Dir.tmpdir) do |file|
FileUtils.cp(file.path, path)
end
path
end
def encode_slate(image_path)
out = temp_path("cover-slate", ".mp4")
cmd = ffmpeg_args(image_path, out)
stdout, stderr, status = Open3.capture3(*cmd)
unless status.success? && File.exist?(out) && File.size(out).positive?
detail = stderr.to_s.strip.presence || stdout.to_s.strip
raise Error, "ffmpeg slate failed: #{detail}"
end
out
end
def ffmpeg_args(input, output)
[
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "error",
"-loop", "1", "-framerate", "30", "-i", input,
"-f", "lavfi", "-i", "anullsrc=r=48000:cl=mono",
"-filter:v", VIDEO_FILTER,
"-map", "0:v", "-map", "1:a",
"-t", SLATE_DURATION_SECS.to_s,
"-c:v", "libx264", "-pix_fmt", "yuv420p", "-profile:v", "baseline", "-level", "3.1",
"-x264-params", "keyint=30:min-keyint=30:scenecut=0:bframes=0",
"-g", "30", "-keyint_min", "30", "-force_key_frames", "expr:gte(t,n_forced*1)",
"-preset", "fast",
"-c:a", "aac", "-b:a", "128k", "-ac", "1", "-shortest", "-y", output
]
end
def attach_slate(mp4_path)
@record.cover_slate.purge if @record.cover_slate.attached?
File.open(mp4_path, "rb") do |io|
@record.cover_slate.attach(
io: io,
filename: slate_filename,
content_type: "video/mp4"
)
end
@record.reload
end
def write_to_slates_dir(mp4_path)
dest = CoverSlatePaths.expected_path(@record)
return unless dest
FileUtils.mkdir_p(File.dirname(dest))
FileUtils.cp(mp4_path, dest)
rescue Errno::EACCES, Errno::EROFS => e
Rails.logger.info("[GenerateCoverSlate] skip disk copy #{dest}: #{e.class}")
end
def slate_filename
"#{@record.class.name.underscore}-#{@record.id}.mp4"
end
def extension_for(content_type)
case content_type
when "image/png" then ".png"
when "image/webp" then ".webp"
else ".jpg"
end
end
def temp_path(prefix, ext)
path = File.join(Dir.tmpdir, "#{prefix}-#{@record.class.name}-#{@record.id}-#{SecureRandom.hex(4)}#{ext}")
@tempfiles ||= []
@tempfiles << path
path
end
def cleanup_tempfiles
Array(@tempfiles).each do |path|
FileUtils.rm_f(path)
end
end
end
end
@@ -0,0 +1,105 @@
module Streams
# Assicura che la slate MP4 custom sia sul nodo assegnato (home/lab locale, CPX via agent).
class SlateDistributor
class Error < StandardError; end
def self.slate_path_for_session(session)
new(session).mediamtx_slate_path
end
def self.ensure_for!(session)
new(session).ensure!
end
def initialize(session)
@session = session
@match = session.match
@node = session.stream_node
@resolver = CoverResolver.new(@match)
end
def mediamtx_slate_path
ensure! unless defined?(@mediamtx_path)
@mediamtx_path
end
def ensure!
result = @resolver.resolve
unless result
@mediamtx_path = default_slate_path
return @mediamtx_path
end
host_path = result.slate_local_path
filename = CoverSlatePaths.filename_for(result.record)
raise Error, "slate filename missing" if filename.blank?
# Path completo (non File.basename): il checksum AS può contenere "/" e spezzerebbe il path.
mediamtx_path = CoverSlatePaths.expected_path(result.record)
if cloud_node?
push_to_agent!(filename, result.record, host_path)
else
verify_local_slate!(host_path)
end
@mediamtx_path = mediamtx_path
rescue StandardError => e
# Agent cloud down / timeout / disk missing: non bloccare il go-live, usa slate default.
Rails.logger.warn("[SlateDistributor] session=#{@session.id} #{e.class}: #{e.message}")
@mediamtx_path = default_slate_path
end
private
def cloud_node?
@node&.role == "cloud" && agent_url.present?
end
def agent_url
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || @node&.relay_agent_url
end
def agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def push_to_agent!(filename, record, host_path)
bytes = read_slate_bytes(record, host_path)
raise Error, "slate bytes missing for #{filename}" if bytes.blank?
uri = URI.parse("#{agent_url.chomp('/')}/slates/#{CGI.escape(filename)}")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 120
req = Net::HTTP::Put.new(uri)
req["Authorization"] = "Bearer #{agent_secret}" if agent_secret.present?
req["Content-Type"] = "video/mp4"
req.body = bytes
res = http.request(req)
return if res.is_a?(Net::HTTPSuccess)
raise Error, "agent PUT /slates/#{filename} HTTP #{res.code} #{res.body.to_s.truncate(200)}"
end
def read_slate_bytes(record, host_path)
return File.binread(host_path) if host_path.present? && File.exist?(host_path)
return unless record.cover_slate.attached?
record.cover_slate.blob.open(tmpdir: Dir.tmpdir) do |file|
return File.binread(file.path)
end
end
def verify_local_slate!(host_path)
return if host_path.present? && File.exist?(host_path)
raise Error, "slate missing on disk: #{host_path}"
end
def default_slate_path
ENV.fetch("MEDIAMTX_SLATE_FILE", "/slates/offline.mp4")
end
end
end
+16 -1
View File
@@ -35,6 +35,20 @@ module Teams
active? && plan.phone_download_enabled? active? && plan.phone_download_enabled?
end end
def can_use_custom_cover?
premium_full? && plan.custom_cover_enabled?
end
def assert_can_upload_cover!
unless can_use_custom_cover?
raise EntitlementError.new(
"La copertina sponsor personalizzata richiede Premium Full",
code: "premium_full_required",
billing_url: billing_url
)
end
end
def max_staff_transmission def max_staff_transmission
plan.max_staff_transmission plan.max_staff_transmission
end end
@@ -225,7 +239,8 @@ module Teams
recording_retention_days: recording_retention_days, recording_retention_days: recording_retention_days,
phone_download_enabled: phone_download_enabled?, phone_download_enabled: phone_download_enabled?,
youtube_enabled: youtube_enabled?, youtube_enabled: youtube_enabled?,
youtube_mode: plan.youtube_mode youtube_mode: plan.youtube_mode,
custom_cover_enabled: can_use_custom_cover?
} }
end end
@@ -23,5 +23,12 @@ module Teams
ut.update!(staff_kind: "transmission") ut.update!(staff_kind: "transmission")
ut ut
end end
def self.designate_club_owner!(team:, user:)
membership = user.user_teams.find_or_initialize_by(team: team)
membership.role = "member" if membership.new_record?
membership.save!
call(team: team, user: user, membership: membership)
end
end end
end end
@@ -0,0 +1,112 @@
<%= 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_android %>
<span><%= t("admin.announcements.channels.android") %></span>
</label>
<label class="admin-checkbox">
<%= f.check_box :show_on_ios %>
<span><%= t("admin.announcements.channels.ios") %></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>
<fieldset class="admin-channels">
<legend><%= t("admin.announcements.form.locales_legend") %></legend>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.locales_hint") %></p>
<div class="admin-locale-tabs" role="tablist">
<% language_options.each do |opt| %>
<button type="button"
class="admin-locale-tab<%= " is-active" if opt[:code].to_s == I18n.default_locale.to_s %>"
data-locale-tab="<%= opt[:code] %>"
role="tab"
aria-selected="<%= opt[:code].to_s == I18n.default_locale.to_s %>">
<span aria-hidden="true"><%= opt[:flag] %></span>
<%= opt[:native] %>
<% if opt[:code].to_s == I18n.default_locale.to_s %>
<span class="muted">(<%= t("admin.announcements.form.locale_required") %>)</span>
<% elsif announcement.filled_locale_codes.include?(opt[:code].to_s) %>
<span class="muted"></span>
<% end %>
</button>
<% end %>
</div>
<% language_options.each do |opt| %>
<div class="admin-locale-panel"
data-locale-panel="<%= opt[:code] %>"
role="tabpanel"
<%= "hidden" unless opt[:code].to_s == I18n.default_locale.to_s %>>
<%= render "locale_copy", f: f, announcement: announcement, locale: opt[:code] %>
</div>
<% end %>
</fieldset>
<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>
<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 %>
<script>
(function () {
var tabs = document.querySelectorAll("[data-locale-tab]");
if (!tabs.length) return;
tabs.forEach(function (btn) {
btn.addEventListener("click", function () {
var loc = btn.getAttribute("data-locale-tab");
tabs.forEach(function (other) {
var on = other === btn;
other.classList.toggle("is-active", on);
other.setAttribute("aria-selected", on ? "true" : "false");
});
document.querySelectorAll("[data-locale-panel]").forEach(function (panel) {
panel.hidden = panel.getAttribute("data-locale-panel") !== loc;
});
});
});
})();
</script>
@@ -0,0 +1,44 @@
<% it = locale.to_s == I18n.default_locale.to_s %>
<% values = announcement.copy_values_for(locale) %>
<% name_base = "app_announcement[translations][#{locale}]" %>
<div>
<% if it %>
<%= f.label :title, t("admin.announcements.form.title") %>
<%= f.text_field :title, required: true, maxlength: 120, id: "ann_#{locale}_title" %>
<% else %>
<label for="ann_<%= locale %>_title"><%= t("admin.announcements.form.title") %></label>
<%= text_field_tag "#{name_base}[title]", values["title"], maxlength: 120, id: "ann_#{locale}_title" %>
<% end %>
</div>
<div>
<% if it %>
<%= f.label :body, t("admin.announcements.form.body") %>
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
<% else %>
<label for="ann_<%= locale %>_body"><%= t("admin.announcements.form.body") %></label>
<%= text_area_tag "#{name_base}[body]", values["body"], rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
<% end %>
</div>
<div>
<% if it %>
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
<%= f.url_field :action_url, placeholder: "https://", id: "ann_#{locale}_action_url" %>
<% else %>
<label for="ann_<%= locale %>_action_url"><%= t("admin.announcements.form.action_url") %></label>
<%= url_field_tag "#{name_base}[action_url]", values["action_url"], placeholder: "https://", id: "ann_#{locale}_action_url" %>
<% end %>
</div>
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
<div>
<% if it %>
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
<%= f.text_field :action_label, maxlength: 40, id: "ann_#{locale}_action_label" %>
<% else %>
<label for="ann_<%= locale %>_action_label"><%= t("admin.announcements.form.action_label") %></label>
<%= text_field_tag "#{name_base}[action_label]", values["action_label"], maxlength: 40, id: "ann_#{locale}_action_label" %>
<% 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.edit.title") %></h2>
</div>
<div class="panel">
<%= render "form", announcement: @announcement %>
</div>
@@ -0,0 +1,57 @@
<% 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>
<div class="muted" style="font-size:0.8rem;margin-top:0.2rem"><%= item.filled_locale_codes.map(&:upcase).join(" · ") %></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>
@@ -42,6 +42,10 @@
</header> </header>
<div class="billing-pending-card__body"> <div class="billing-pending-card__body">
<p style="margin:0 0 12px"> <p style="margin:0 0 12px">
<%= t("admin.billing.index.transfers_table.holder") %>:
<strong><%= MatchLiveTv.bank_transfer_account_holder %></strong><br>
<%= t("admin.billing.index.transfers_table.iban") %>:
<strong><%= MatchLiveTv.bank_transfer_iban %></strong><br>
<%= t("admin.billing.index.transfers_table.causal") %>: <%= t("admin.billing.index.transfers_table.causal") %>:
<strong><%= order.payment_causal %></strong> <strong><%= order.payment_causal %></strong>
</p> </p>
@@ -107,6 +107,7 @@
<tr> <tr>
<th><%= t("admin.dashboard.sessions.table.match") %></th> <th><%= t("admin.dashboard.sessions.table.match") %></th>
<th><%= t("admin.dashboard.sessions.table.status") %></th> <th><%= t("admin.dashboard.sessions.table.status") %></th>
<th><%= t("admin.dashboard.sessions.table.ingest") %></th>
<th><%= t("admin.dashboard.sessions.table.start") %></th> <th><%= t("admin.dashboard.sessions.table.start") %></th>
<th><%= t("admin.dashboard.sessions.table.link") %></th> <th><%= t("admin.dashboard.sessions.table.link") %></th>
<th></th> <th></th>
@@ -117,12 +118,15 @@
<tr> <tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td> <td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td> <td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
<td class="admin-link-compact"> <td>
<div class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %> <% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %> <%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %> <% end %>
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %> <%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</div>
</td> </td>
<td class="admin-actions"> <td class="admin-actions">
<%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %> <%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %>
@@ -0,0 +1,9 @@
<% node = session.stream_node %>
<% if node %>
<div class="admin-ingest">
<code class="admin-ingest__slug"><%= node.slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span>
</div>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %>
@@ -4,6 +4,7 @@
<tr> <tr>
<th><%= t("admin.sessions.index.table.match") %></th> <th><%= t("admin.sessions.index.table.match") %></th>
<th><%= t("admin.sessions.index.table.status") %></th> <th><%= t("admin.sessions.index.table.status") %></th>
<th><%= t("admin.sessions.index.table.ingest") %></th>
<th><%= t("admin.sessions.index.table.disconnects") %></th> <th><%= t("admin.sessions.index.table.disconnects") %></th>
<th><%= t("admin.sessions.index.table.link") %></th> <th><%= t("admin.sessions.index.table.link") %></th>
<th></th> <th></th>
@@ -14,12 +15,15 @@
<tr> <tr>
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td> <td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td> <td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
<td><%= s.disconnection_count %></td> <td><%= s.disconnection_count %></td>
<td class="admin-link-compact"> <td>
<div class="admin-link-compact">
<% admin_session_watch_links(s).each do |link| %> <% admin_session_watch_links(s).each do |link| %>
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %> <%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
<% end %> <% end %>
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %> <%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
</div>
</td> </td>
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td> <td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
</tr> </tr>
@@ -16,6 +16,16 @@
<% if @session.youtube_broadcast_id %> <% if @session.youtube_broadcast_id %>
<p><%= t("admin.sessions.show.youtube_studio") %>: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener"><%= t("admin.sessions.show.broadcast") %></a></p> <p><%= t("admin.sessions.show.youtube_studio") %>: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener"><%= t("admin.sessions.show.broadcast") %></a></p>
<% end %> <% end %>
<p>
<%= t("admin.sessions.show.ingest_node") %>
<% if @session.stream_node %>
<code><%= @session.stream_node.slug %></code>
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>"><%= admin_session_ingest_role_label(@session.stream_node) %></span>
<span class="muted">(<%= @session.stream_node.provider %>)</span>
<% else %>
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
<% end %>
</p>
<p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p> <p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p>
<h3><%= t("admin.sessions.show.events_title") %></h3> <h3><%= t("admin.sessions.show.events_title") %></h3>
@@ -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") %>
+3 -2
View File
@@ -4,7 +4,7 @@
<title><%= t("admin.layout.title") %></title> <title><%= t("admin.layout.title") %></title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="/admin.css?v=4"> <link rel="stylesheet" href="/admin.css?v=11">
<% if content_for?(:replay_archive_styles) %> <% if content_for?(:replay_archive_styles) %>
<link rel="stylesheet" href="/marketing.css?v=42"> <link rel="stylesheet" href="/marketing.css?v=42">
<% end %> <% end %>
@@ -13,7 +13,7 @@
<script src="/admin-dashboard.js?v=1" defer></script> <script src="/admin-dashboard.js?v=1" defer></script>
<% end %> <% end %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=6" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
</head> </head>
<body class="<%= content_for?(:body_class) ? yield(:body_class) : 'admin-body' %>"> <body class="<%= content_for?(:body_class) ? yield(:body_class) : 'admin-body' %>">
<header class="admin-header"> <header class="admin-header">
@@ -25,6 +25,7 @@
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %> <%= 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 %> <%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
<% 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.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.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") %> <%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
+3 -2
View File
@@ -8,11 +8,12 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= 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="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=68">
</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 %>> <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/cookie_banner" %>
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %> <%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
<%= render "shared/site_announcements" %>
<main> <main>
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %> <% 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 %> <% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
@@ -23,7 +24,7 @@
<script src="/roster-form.js?v=1" defer></script> <script src="/roster-form.js?v=1" defer></script>
<script src="/password-toggle.js?v=2" defer></script> <script src="/password-toggle.js?v=2" defer></script>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=6" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/cookie-consent.js?v=1" defer></script> <script src="/cookie-consent.js?v=1" defer></script>
</body> </body>
</html> </html>
@@ -6,19 +6,20 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %> <%= 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="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=68">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</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 %>> <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/cookie_banner" %>
<%= render "shared/marketing_nav" %> <%= render "shared/marketing_nav" %>
<%= render "shared/site_announcements" %>
<main class="live-main"> <main class="live-main">
<%= yield %> <%= yield %>
</main> </main>
<%= render "shared/marketing_footer" %> <%= render "shared/marketing_footer" %>
<link rel="stylesheet" href="/confirm-forms.css?v=4"> <link rel="stylesheet" href="/confirm-forms.css?v=4">
<script src="/confirm-forms.js?v=6" defer></script> <script src="/confirm-forms.js?v=7" defer></script>
<script src="/cookie-consent.js?v=1" defer></script> <script src="/cookie-consent.js?v=1" defer></script>
</body> </body>
</html> </html>
@@ -18,7 +18,7 @@
</div> </div>
<% else %> <% else %>
<% if @quote %> <% if @quote %>
<%= render "shared/quoted_price_banner", quote: @quote %> <%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
<% elsif MatchLiveTv.stripe_enabled? %> <% elsif MatchLiveTv.stripe_enabled? %>
<%= render "shared/stripe_secure_payment" %> <%= render "shared/stripe_secure_payment" %>
<% end %> <% end %>
+4 -1
View File
@@ -1,7 +1,7 @@
<% content_for :title, t("club.edit.title", name: @club.name) %> <% content_for :title, t("club.edit.title", name: @club.name) %>
<% content_for :robots, "noindex, nofollow" %> <% 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> <h1><%= t("club.edit.heading") %></h1>
<div class="card"> <div class="card">
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %> <%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
@@ -10,6 +10,9 @@
<%= label_tag "club[sport]", t("club.sport_label") %> <%= label_tag "club[sport]", t("club.sport_label") %>
<%= select_tag "club[sport]", sport_catalog_options(@club.sport) %> <%= select_tag "club[sport]", sport_catalog_options(@club.sport) %>
<%= render "shared/branding_fields", record: @club, legend: t("club.branding_legend") %> <%= render "shared/branding_fields", record: @club, legend: t("club.branding_legend") %>
<%= render "shared/cover_fields",
record: @club,
can_upload: @entitlements&.can_use_custom_cover? %>
<%= render "shared/billing_profile_fields", record: @club %> <%= render "shared/billing_profile_fields", record: @club %>
<%= submit_tag t("club.edit.save"), class: "btn btn-primary" %> <%= submit_tag t("club.edit.save"), class: "btn btn-primary" %>
<% end %> <% end %>
+2 -9
View File
@@ -24,15 +24,8 @@
[t("club.new.plan_free"), "free"], [t("club.new.plan_free"), "free"],
[t("club.new.plan_light"), "premium_light"], [t("club.new.plan_light"), "premium_light"],
[t("club.new.plan_full"), "premium_full"] [t("club.new.plan_full"), "premium_full"]
], params[:plan] || "free"), id: "club_plan_select" %> ], params[:plan] || "free") %>
<div id="club_plan_interval" style="margin-top:10px"> <p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
<%= label_tag :interval, t("club.new.interval_label") %>
<%= select_tag :interval, options_for_select([
[t("club.new.interval_yearly"), "yearly"],
[t("club.new.interval_monthly"), "monthly"]
], params[:interval] || "yearly") %>
</div>
<%= render "shared/stripe_secure_payment", compact: true %>
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %> <%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
<% end %> <% end %>
</div> </div>
@@ -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>
+3 -2
View File
@@ -46,7 +46,7 @@
</div> </div>
<% else %> <% else %>
<div class="live-player-wrap"> <div class="live-player-wrap">
<video id="player" controls playsinline muted autoplay poster="/images/copertina-canale.png"></video> <video id="player" controls playsinline muted autoplay poster="<%= @cover_poster_url %>"></video>
<%= render "public/live/player_overlays", match: @match, session: @session %> <%= render "public/live/player_overlays", match: @match, session: @session %>
<button type="button" id="play-hint" class="live-play-hint" hidden> <button type="button" id="play-hint" class="live-play-hint" hidden>
<%= t("live.show.play_hint") %> <%= t("live.show.play_hint") %>
@@ -219,7 +219,8 @@
} }
function onEnterPause() { function onEnterPause() {
nudgePlayback(); // Camera → slate cambia risoluzione/codec: senza reload HLS.js resta spesso bloccato.
hardReloadPlayer();
} }
function hardReloadPlayer() { function hardReloadPlayer() {
@@ -83,6 +83,6 @@
<div class="card" style="margin-top:32px;text-align:center"> <div class="card" style="margin-top:32px;text-align:center">
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3> <h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p> <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>
</div> </div>
@@ -43,6 +43,7 @@
<li><%= raw t("legal.privacy.s3_item4_html") %></li> <li><%= raw t("legal.privacy.s3_item4_html") %></li>
<li><%= raw t("legal.privacy.s3_item5_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_item6_html") %></li>
<li><%= raw t("legal.privacy.s3_item7_html") %></li>
</ul> </ul>
</section> </section>
@@ -107,6 +108,10 @@
<td><%= t("legal.privacy.s5_row6_purpose") %></td> <td><%= t("legal.privacy.s5_row6_purpose") %></td>
<td><%= t("legal.privacy.s5_row6_basis") %></td> <td><%= t("legal.privacy.s5_row6_basis") %></td>
</tr> </tr>
<tr>
<td><%= t("legal.privacy.s5_row7_purpose") %></td>
<td><%= t("legal.privacy.s5_row7_basis") %></td>
</tr>
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -144,6 +149,7 @@
<li><%= raw t("legal.privacy.s8_item2_html") %></li> <li><%= raw t("legal.privacy.s8_item2_html") %></li>
<li><%= raw t("legal.privacy.s8_item3_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_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
<li><%= raw t("legal.privacy.s8_item5_html") %></li>
</ul> </ul>
</section> </section>
+1 -1
View File
@@ -165,4 +165,4 @@
</div> </div>
</div> </div>
<script src="/regia.js?v=19"></script> <script src="/regia.js?v=20"></script>
@@ -1,33 +1,33 @@
<% team = local_assigns[:team] %> <% team = local_assigns[:team] %>
<% club = local_assigns[:club] %>
<% can_manage = local_assigns[:can_manage] %> <% can_manage = local_assigns[:can_manage] %>
<% owner_membership = local_assigns[:owner_membership] %>
<% staff_memberships = local_assigns[:staff_memberships] %> <% staff_memberships = local_assigns[:staff_memberships] %>
<% pending_invitations = local_assigns[:pending_invitations] %> <% pending_invitations = local_assigns[:pending_invitations] %>
<% recordings = local_assigns[:recordings] %> <% recordings = local_assigns[:recordings] %>
<% entitlements = local_assigns[:entitlements] %> <% entitlements = local_assigns[:entitlements] %>
<% club_owner = club.owner %>
<% extra_staff = staff_memberships.reject { |ut| ut.user_id == club_owner&.id } %>
<section class="team-streaming-staff" id="responsabili-trasmissione"> <section class="team-streaming-staff" id="responsabili-trasmissione">
<% if can_manage %> <% if club_owner %>
<% if owner_membership&.staff_kind.blank? %>
<div class="card team-streaming-staff__self"> <div class="card team-streaming-staff__self">
<h2><%= t("team.streaming_staff.self_account_heading", email: current_user.email) %></h2> <h2><%= t("team.streaming_staff.principal_heading") %></h2>
<p>
<%= raw t("team.streaming_staff.principal_body_html", email: club_owner.email) %>
</p>
<p class="muted"> <p class="muted">
<%= raw t("team.streaming_staff.self_account_hint") %> <%= raw t("team.streaming_staff.principal_score_hint_html") %>
</p> </p>
<div class="team-details-actions">
<%= button_to t("team.streaming_staff.self_assign_submit"), public_team_assign_self_staff_path(team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
</div> </div>
</div>
<% end %>
<% end %> <% end %>
<h2><%= t("team.streaming_staff.staff_heading") %></h2> <% if extra_staff.any? %>
<h2><%= t("team.streaming_staff.extra_staff_heading") %></h2>
<div class="card"> <div class="card">
<% if staff_memberships.any? %>
<table class="data"> <table class="data">
<thead><tr><th><%= t("team.streaming_staff.col_name") %></th><th><%= t("team.streaming_staff.col_email") %></th><th><%= t("team.streaming_staff.col_role") %></th><% if can_manage %><th></th><% end %></tr></thead> <thead><tr><th><%= t("team.streaming_staff.col_name") %></th><th><%= t("team.streaming_staff.col_email") %></th><th><%= t("team.streaming_staff.col_role") %></th><% if can_manage %><th></th><% end %></tr></thead>
<tbody> <tbody>
<% staff_memberships.each do |ut| %> <% extra_staff.each do |ut| %>
<tr> <tr>
<td> <td>
<%= ut.user.name %> <%= ut.user.name %>
@@ -37,9 +37,7 @@
<td><%= t("team.streaming_staff.role_transmission") %></td> <td><%= t("team.streaming_staff.role_transmission") %></td>
<% if can_manage %> <% if can_manage %>
<td> <td>
<% if ut.user_id == current_user.id && team.club.owned_by?(current_user) %> <% if ut.role == "member" %>
<%= button_to t("team.streaming_staff.clear_self_staff"), public_team_clear_self_staff_path(team), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: t("team.streaming_staff.clear_self_staff_confirm"), confirm_kind: "delete" } } %>
<% elsif ut.role == "member" %>
<%= button_to t("team.streaming_staff.revoke"), public_team_remove_member_path(team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: t("team.streaming_staff.revoke_confirm", name: ut.user.name), confirm_kind: "delete" } } %> <%= button_to t("team.streaming_staff.revoke"), public_team_remove_member_path(team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: t("team.streaming_staff.revoke_confirm", name: ut.user.name), confirm_kind: "delete" } } %>
<% end %> <% end %>
</td> </td>
@@ -48,13 +46,8 @@
<% end %> <% end %>
</tbody> </tbody>
</table> </table>
<% else %>
<p class="muted">
<%= t("team.streaming_staff.no_staff") %>
<% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), "#invita-trasmissione" %>.<% end %>
</p>
<% end %>
</div> </div>
<% end %>
<% if can_manage %> <% if can_manage %>
<div class="card team-invite-form" id="invita-trasmissione"> <div class="card team-invite-form" id="invita-trasmissione">
+5 -1
View File
@@ -1,7 +1,7 @@
<% content_for :title, t("team.edit.title", name: @team.name) %> <% content_for :title, t("team.edit.title", name: @team.name) %>
<% content_for :robots, "noindex, nofollow" %> <% 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> <h1><%= @team.name %></h1>
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p> <p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
<div class="card"> <div class="card">
@@ -25,6 +25,10 @@
</div> </div>
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: t("team.edit.branding_legend") %> <%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: t("team.edit.branding_legend") %>
<%= render "shared/cover_fields",
record: @team,
show_inherit_hint: true,
can_upload: @entitlements&.can_use_custom_cover? %>
<%= label_tag "team[slug]", t("team.edit.slug_label") %> <%= label_tag "team[slug]", t("team.edit.slug_label") %>
<p class="branding-hint" style="margin-top:0"> <p class="branding-hint" style="margin-top:0">
@@ -0,0 +1,82 @@
<% record = local_assigns[:record] %>
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
<% can_upload = local_assigns.fetch(:can_upload, false) %>
<% 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>
<% if show_inherit_hint %>
<p class="branding-hint"><%= t("club.branding.cover_inherit_hint") %></p>
<% end %>
<p class="branding-hint"><%= t("club.branding.cover_hint") %></p>
<div class="branding-logo-block cover-preview-block">
<%= label_tag "branding[cover_image]", t("club.branding.cover_file_label") %>
<div class="branding-logo-preview branding-cover-preview" data-branding-cover-preview>
<%= image_tag cover_src,
alt: t("club.branding.cover_preview_alt"),
class: "branding-cover-preview__img",
data: { branding_cover_img: true } %>
</div>
<% if can_upload %>
<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" %>
<% end %>
</div>
</fieldset>
<script>
(function () {
var input = document.querySelector("[data-branding-cover-file]");
var img = document.querySelector("[data-branding-cover-img]");
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" %> <%= render "shared/store_badges", variant: "footer" %>
</div> </div>
<div> <div>
<%= link_to t("common.contacts"), public_contatti_path %> ·
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> · <%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> · <%= link_to t("footer.live"), public_live_index_path %> ·
@@ -1,15 +1,10 @@
<%# locals: (order:) %> <%# locals: (order:) %>
<% return if order.blank? %> <% return if order.blank? %>
<div class="card" style="margin-top:16px;border-color:#3d3520;background:#1a1810"> <div class="card" style="margin-top:16px;border-color:#3d3520;background:#1a1810">
<p style="margin:0 0 8px;color:#ddd"> <p style="margin:0;color:#ddd">
<strong><%= t("billing.bank_transfer.pending_title") %></strong> <strong><%= t("billing.bank_transfer.pending_title") %></strong>
<%= raw t("billing.bank_transfer.pending_body_html", <%= raw t("billing.bank_transfer.pending_body_html",
plan: order.plan.name, plan: order.plan.name,
price: order.price_label, price: order.price_label) %>
causal: order.payment_causal) %>
</p>
<p style="margin:0;color:#aaa;font-size:0.9rem">
<%= raw t("billing.bank_transfer.pending_proof_html",
email_link: mail_to(MatchLiveTv.bank_transfer_proof_email, MatchLiveTv.bank_transfer_proof_email)) %>
</p> </p>
</div> </div>
@@ -109,7 +109,7 @@
public_club_billing_bank_transfer_path(club, plan: plan.slug, interval: interval), public_club_billing_bank_transfer_path(club, plan: plan.slug, interval: interval),
method: :post, method: :post,
class: "btn btn-outline plan-interval-btn", class: "btn btn-outline plan-interval-btn",
form: { data: { turbo_confirm: t("billing.bank_transfer.hint") } } %> form: { data: { turbo_confirm: t("billing.bank_transfer.confirm") } } %>
<% end %> <% end %>
</div> </div>
<p class="plan-action-hint" style="margin-top:8px"><%= t("billing.bank_transfer.hint") %></p> <p class="plan-action-hint" style="margin-top:8px"><%= t("billing.bank_transfer.hint") %></p>
@@ -1,11 +1,19 @@
<%# locals: (quote:) %> <%# locals: (quote:, subscription: nil) %>
<% return if quote.blank? %> <% return if quote.blank? %>
<% quoted_plan_active = current_paid_plan?(subscription, quote.plan) %>
<div class="card" style="margin-top:16px;border-color:#2e5a3c;background:#142018"> <div class="card" style="margin-top:16px;border-color:#2e5a3c;background:#142018">
<p style="margin:0;color:#ddd"> <p style="margin:0;color:#ddd">
<% if quoted_plan_active %>
<strong><%= t("billing.bank_transfer.quote_banner_active_title") %></strong>
<%= raw t(
"billing.bank_transfer.quote_banner_active_body_html",
plan: quote.plan.name,
price: quote.price_label,
date: subscription&.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : t("billing.subscription_status.end_of_period")
) %>
<% else %>
<strong><%= t("billing.bank_transfer.quote_banner_title") %></strong> <strong><%= t("billing.bank_transfer.quote_banner_title") %></strong>
<%= raw t("billing.bank_transfer.quote_banner_body_html", plan: quote.plan.name, price: quote.price_label) %> <%= raw t("billing.bank_transfer.quote_banner_body_html", plan: quote.plan.name, price: quote.price_label) %>
</p>
<% if quote.note.present? %>
<p style="margin:8px 0 0;color:#aaa;font-size:0.9rem"><%= t("billing.bank_transfer.quote_note", note: quote.note) %></p>
<% end %> <% end %>
</p>
</div> </div>
@@ -0,0 +1,46 @@
<% 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.localized_title %></p>
<div class="site-announcement__text"><%= simple_format(item.localized_body) %></div>
<% action_url = item.resolved_action_url(nil) %>
<% if action_url.present? %>
<p class="site-announcement__action">
<%= link_to (item.localized_action_label.presence || t("announcement.open")), 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 %>
+16 -1
View File
@@ -21,7 +21,7 @@ module MatchLiveTv
end end
def hls_public_url def hls_public_url
ENV.fetch("HLS_PUBLIC_URL", "http://localhost:8888") ENV.fetch("HLS_PUBLIC_URL") { "#{app_public_url.chomp('/')}/hls" }
end end
def mediamtx_hls_url def mediamtx_hls_url
@@ -97,6 +97,21 @@ module MatchLiveTv
bank_transfer_iban.present? && bank_transfer_account_holder.present? bank_transfer_iban.present? && bank_transfer_account_holder.present?
end end
def smtp_configured?
ENV["SMTP_ADDRESS"].present?
end
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
def deliver_mail(mail)
if Rails.env.production? && !smtp_configured?
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
return false
end
mail.deliver_now
true
end
def privacy_controller_name def privacy_controller_name
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro") ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
end end
+80 -1
View File
@@ -5,6 +5,7 @@ de:
nav: nav:
dashboard: Dashboard dashboard: Dashboard
ops: Ops ops: Ops
announcements: Hinweise
clubs: Vereine & Teams clubs: Vereine & Teams
billing: Abrechnung billing: Abrechnung
youtube: YouTube youtube: YouTube
@@ -45,6 +46,9 @@ de:
invoice_updated: "Rechnung %{number} aktualisiert." invoice_updated: "Rechnung %{number} aktualisiert."
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet." invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env 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: common:
free_plan: Free free_plan: Free
yes: "Ja" yes: "Ja"
@@ -57,7 +61,7 @@ de:
username_label: Benutzername username_label: Benutzername
password_label: Passwort password_label: Passwort
submit: Anmelden submit: Anmelden
initial_credentials_html: "Anfangs-Zugangsdaten: <code>admin</code> / <code>admin</code>. Ändere das Passwort nach der ersten Anmeldung." initial_credentials_html: "Anfangs-Zugangsdaten: <code>admin</code> / <code>AdminPass123</code>. Ändere das Passwort nach der ersten Anmeldung."
passwords: passwords:
edit: edit:
title: Passwort ändern title: Passwort ändern
@@ -106,6 +110,7 @@ de:
table: table:
match: Spiel match: Spiel
status: Status status: Status
ingest: Ingest
start: Start start: Start
link: Link link: Link
none: Derzeit keine aktive Sitzung. none: Derzeit keine aktive Sitzung.
@@ -249,6 +254,7 @@ de:
table: table:
match: Spiel match: Spiel
status: Status status: Status
ingest: Ingest
disconnects: Verbindungsabbrüche disconnects: Verbindungsabbrüche
link: Link link: Link
detail: Details detail: Details
@@ -261,6 +267,7 @@ de:
match_label: "Spiel: %{team} gegen %{opponent}" match_label: "Spiel: %{team} gegen %{opponent}"
youtube_studio: YouTube Studio youtube_studio: YouTube Studio
broadcast: Übertragung broadcast: Übertragung
ingest_node: "Ingest-Knoten:"
rtmp_ingest: "RTMP-Ingest:" rtmp_ingest: "RTMP-Ingest:"
events_title: Ereignisse events_title: Ereignisse
table: table:
@@ -277,6 +284,13 @@ de:
active_exists: Es existiert bereits ein aktiver Regie-Link (die URL kann nicht abgerufen werden). Erzeuge einen neuen, wenn du ihn erneut teilen musst. active_exists: Es existiert bereits ein aktiver Regie-Link (die URL kann nicht abgerufen werden). Erzeuge einen neuen, wenn du ihn erneut teilen musst.
none_active: Kein aktiver Regie-Link. Erzeuge einen Link zum Teilen mit der Person, die den Spielstand verwaltet. none_active: Kein aktiver Regie-Link. Erzeuge einen Link zum Teilen mit der Person, die den Spielstand verwaltet.
generate_button: Regie-Link erzeugen generate_button: Regie-Link erzeugen
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
billing: billing:
index: index:
title: Zu berechnende Zahlungen title: Zu berechnende Zahlungen
@@ -360,3 +374,68 @@ de:
club_recordings: club_recordings:
index: index:
title: "Replays — %{club}" 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:
android: Android-App
ios: iOS-App
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. Android und iOS sind unabhängig: bei unterschiedlichem Text zwei Hinweise anlegen. 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 der Plattform (Play Store auf Android, App Store auf iOS).
action_label: Button-Text
locales_legend: Text pro Sprache
locales_hint: Alle App-Sprachen ausfüllen (Italienisch, Englisch, Französisch, Deutsch, Spanisch). Italienisch ist Pflicht; fehlende Sprachen fallen auf Englisch, dann Italienisch zurück.
locale_required: Pflicht
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
+80 -1
View File
@@ -5,6 +5,7 @@ en:
nav: nav:
dashboard: Dashboard dashboard: Dashboard
ops: Ops ops: Ops
announcements: Notices
clubs: Clubs & teams clubs: Clubs & teams
billing: Billing billing: Billing
youtube: YouTube youtube: YouTube
@@ -45,6 +46,9 @@ en:
invoice_updated: "Invoice %{number} updated." invoice_updated: "Invoice %{number} updated."
invoice_uploaded: "Invoice uploaded and sent to %{email}." invoice_uploaded: "Invoice uploaded and sent to %{email}."
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env 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: common:
free_plan: Free free_plan: Free
yes: "Yes" yes: "Yes"
@@ -57,7 +61,7 @@ en:
username_label: Username username_label: Username
password_label: Password password_label: Password
submit: Log in submit: Log in
initial_credentials_html: "Initial credentials: <code>admin</code> / <code>admin</code>. Change the password after the first login." initial_credentials_html: "Initial credentials: <code>admin</code> / <code>AdminPass123</code>. Change the password after the first login."
passwords: passwords:
edit: edit:
title: Change password title: Change password
@@ -106,6 +110,7 @@ en:
table: table:
match: Match match: Match
status: Status status: Status
ingest: Ingest
start: Start start: Start
link: Link link: Link
none: No active session right now. none: No active session right now.
@@ -249,6 +254,7 @@ en:
table: table:
match: Match match: Match
status: Status status: Status
ingest: Ingest
disconnects: Disconnects disconnects: Disconnects
link: Link link: Link
detail: Details detail: Details
@@ -261,6 +267,7 @@ en:
match_label: "Match: %{team} vs %{opponent}" match_label: "Match: %{team} vs %{opponent}"
youtube_studio: YouTube Studio youtube_studio: YouTube Studio
broadcast: Broadcast broadcast: Broadcast
ingest_node: "Ingest node:"
rtmp_ingest: "RTMP ingest:" rtmp_ingest: "RTMP ingest:"
events_title: Events events_title: Events
table: table:
@@ -277,6 +284,13 @@ en:
active_exists: An active control link already exists (the URL cannot be retrieved). Generate a new one if you need to share it again. active_exists: An active control link already exists (the URL cannot be retrieved). Generate a new one if you need to share it again.
none_active: No active control link. Generate a link to share with whoever manages the score. none_active: No active control link. Generate a link to share with whoever manages the score.
generate_button: Generate control link generate_button: Generate control link
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
billing: billing:
index: index:
title: Payments to invoice title: Payments to invoice
@@ -360,3 +374,68 @@ en:
club_recordings: club_recordings:
index: index:
title: "Replays — %{club}" 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:
android: Android app
ios: iOS app
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. Android and iOS are independent: create two notices if the copy should differ. 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 that platform's store (Play Store on Android, App Store on iOS).
action_label: Button label
locales_legend: Copy by language
locales_hint: Fill in every app language (Italian, English, French, German, Spanish). Italian is required; missing languages fall back to English, then Italian.
locale_required: required
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
+80 -1
View File
@@ -5,6 +5,7 @@ es:
nav: nav:
dashboard: Panel dashboard: Panel
ops: Ops ops: Ops
announcements: Avisos
clubs: Clubes y equipos clubs: Clubes y equipos
billing: Facturación billing: Facturación
youtube: YouTube youtube: YouTube
@@ -45,6 +46,9 @@ es:
invoice_updated: "Factura %{number} actualizada." invoice_updated: "Factura %{number} actualizada."
invoice_uploaded: "Factura subida y enviada a %{email}." invoice_uploaded: "Factura subida y enviada a %{email}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env 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: common:
free_plan: Free free_plan: Free
yes: "Sí" yes: "Sí"
@@ -57,7 +61,7 @@ es:
username_label: Usuario username_label: Usuario
password_label: Contraseña password_label: Contraseña
submit: Acceder submit: Acceder
initial_credentials_html: "Credenciales iniciales: <code>admin</code> / <code>admin</code>. Cambia la contraseña después del primer acceso." initial_credentials_html: "Credenciales iniciales: <code>admin</code> / <code>AdminPass123</code>. Cambia la contraseña después del primer acceso."
passwords: passwords:
edit: edit:
title: Cambiar contraseña title: Cambiar contraseña
@@ -106,6 +110,7 @@ es:
table: table:
match: Partido match: Partido
status: Estado status: Estado
ingest: Ingest
start: Inicio start: Inicio
link: Enlace link: Enlace
none: No hay ninguna sesión activa en este momento. none: No hay ninguna sesión activa en este momento.
@@ -249,6 +254,7 @@ es:
table: table:
match: Partido match: Partido
status: Estado status: Estado
ingest: Ingest
disconnects: Desconexiones disconnects: Desconexiones
link: Enlace link: Enlace
detail: Detalle detail: Detalle
@@ -261,6 +267,7 @@ es:
match_label: "Partido: %{team} vs %{opponent}" match_label: "Partido: %{team} vs %{opponent}"
youtube_studio: YouTube Studio youtube_studio: YouTube Studio
broadcast: Emisión broadcast: Emisión
ingest_node: "Nodo ingest:"
rtmp_ingest: "Ingesta RTMP:" rtmp_ingest: "Ingesta RTMP:"
events_title: Eventos events_title: Eventos
table: table:
@@ -277,6 +284,13 @@ es:
active_exists: Ya existe un enlace de regie activo (la URL no se puede recuperar). Genera uno nuevo si necesitas compartirlo de nuevo. active_exists: Ya existe un enlace de regie activo (la URL no se puede recuperar). Genera uno nuevo si necesitas compartirlo de nuevo.
none_active: No hay ningún enlace de regie activo. Genera un enlace para compartir con quien gestione el marcador. none_active: No hay ningún enlace de regie activo. Genera un enlace para compartir con quien gestione el marcador.
generate_button: Generar enlace de regie generate_button: Generar enlace de regie
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
billing: billing:
index: index:
title: Pagos por facturar title: Pagos por facturar
@@ -360,3 +374,68 @@ es:
club_recordings: club_recordings:
index: index:
title: "Repeticiones — %{club}" 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:
android: App Android
ios: App 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. Android e iOS son independientes: si el texto debe ser distinto, crea dos avisos. 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 de esa plataforma (Play Store en Android, App Store en iOS).
action_label: Texto del botón
locales_legend: Texto por idioma
locales_hint: Completa todos los idiomas de la app (italiano, inglés, francés, alemán, español). El italiano es obligatorio; si falta un idioma, se usa inglés y luego italiano.
locale_required: obligatorio
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
+80 -1
View File
@@ -5,6 +5,7 @@ fr:
nav: nav:
dashboard: Tableau de bord dashboard: Tableau de bord
ops: Ops ops: Ops
announcements: Alertes
clubs: Clubs et équipes clubs: Clubs et équipes
billing: Facturation billing: Facturation
youtube: YouTube youtube: YouTube
@@ -45,6 +46,9 @@ fr:
invoice_updated: "Facture %{number} mise à jour." invoice_updated: "Facture %{number} mise à jour."
invoice_uploaded: "Facture chargée et envoyée à %{email}." invoice_uploaded: "Facture chargée et envoyée à %{email}."
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env 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: common:
free_plan: Free free_plan: Free
yes: "Oui" yes: "Oui"
@@ -57,7 +61,7 @@ fr:
username_label: Nom d'utilisateur username_label: Nom d'utilisateur
password_label: Mot de passe password_label: Mot de passe
submit: Se connecter submit: Se connecter
initial_credentials_html: "Identifiants initiaux : <code>admin</code> / <code>admin</code>. Changez le mot de passe après la première connexion." initial_credentials_html: "Identifiants initiaux : <code>admin</code> / <code>AdminPass123</code>. Changez le mot de passe après la première connexion."
passwords: passwords:
edit: edit:
title: Changer le mot de passe title: Changer le mot de passe
@@ -106,6 +110,7 @@ fr:
table: table:
match: Match match: Match
status: Statut status: Statut
ingest: Ingest
start: Début start: Début
link: Lien link: Lien
none: Aucune session active pour le moment. none: Aucune session active pour le moment.
@@ -249,6 +254,7 @@ fr:
table: table:
match: Match match: Match
status: Statut status: Statut
ingest: Ingest
disconnects: Déconnexions disconnects: Déconnexions
link: Lien link: Lien
detail: Détail detail: Détail
@@ -261,6 +267,7 @@ fr:
match_label: "Match : %{team} contre %{opponent}" match_label: "Match : %{team} contre %{opponent}"
youtube_studio: YouTube Studio youtube_studio: YouTube Studio
broadcast: Diffusion broadcast: Diffusion
ingest_node: "Nœud ingest :"
rtmp_ingest: "Ingestion RTMP :" rtmp_ingest: "Ingestion RTMP :"
events_title: Événements events_title: Événements
table: table:
@@ -277,6 +284,13 @@ fr:
active_exists: Un lien de régie actif existe déjà (l'URL n'est pas récupérable). Générez-en un nouveau si vous devez le repartager. active_exists: Un lien de régie actif existe déjà (l'URL n'est pas récupérable). Générez-en un nouveau si vous devez le repartager.
none_active: Aucun lien de régie actif. Générez un lien à partager avec la personne qui gère le score. none_active: Aucun lien de régie actif. Générez un lien à partager avec la personne qui gère le score.
generate_button: Générer le lien de régie generate_button: Générer le lien de régie
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
billing: billing:
index: index:
title: Paiements à facturer title: Paiements à facturer
@@ -360,3 +374,68 @@ fr:
club_recordings: club_recordings:
index: index:
title: "Replays — %{club}" 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:
android: App Android
ios: App 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. Android et iOS sont indépendants : créez deux alertes si le texte doit différer. 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 de la plateforme (Play Store sur Android, App Store sur iOS).
action_label: Libellé du bouton
locales_legend: Texte par langue
locales_hint: Renseignez toutes les langues de l'app (italien, anglais, français, allemand, espagnol). L'italien est obligatoire ; les langues manquantes basculent vers l'anglais puis l'italien.
locale_required: obligatoire
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
+82 -1
View File
@@ -5,6 +5,7 @@ it:
nav: nav:
dashboard: Dashboard dashboard: Dashboard
ops: Ops ops: Ops
announcements: Avvisi
clubs: Società e squadre clubs: Società e squadre
billing: Fatturazione billing: Fatturazione
youtube: YouTube youtube: YouTube
@@ -49,6 +50,9 @@ it:
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}." transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato. transfer_cancelled: Bonifico in attesa annullato.
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env 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: common:
free_plan: Free free_plan: Free
yes: "Sì" yes: "Sì"
@@ -61,7 +65,7 @@ it:
username_label: Username username_label: Username
password_label: Password password_label: Password
submit: Accedi submit: Accedi
initial_credentials_html: "Credenziali iniziali: <code>admin</code> / <code>admin</code>. Cambia la password dopo il primo accesso." initial_credentials_html: "Credenziali iniziali: <code>admin</code> / <code>AdminPass123</code>. Cambia la password dopo il primo accesso."
passwords: passwords:
edit: edit:
title: Cambia password title: Cambia password
@@ -110,6 +114,7 @@ it:
table: table:
match: Partita match: Partita
status: Stato status: Stato
ingest: Ingest
start: Inizio start: Inizio
link: Link link: Link
none: Nessuna sessione attiva in questo momento. none: Nessuna sessione attiva in questo momento.
@@ -270,6 +275,7 @@ it:
table: table:
match: Match match: Match
status: Stato status: Stato
ingest: Ingest
disconnects: Disconnessioni disconnects: Disconnessioni
link: Link link: Link
detail: Dettaglio detail: Dettaglio
@@ -282,6 +288,7 @@ it:
match_label: "Match: %{team} vs %{opponent}" match_label: "Match: %{team} vs %{opponent}"
youtube_studio: YouTube Studio youtube_studio: YouTube Studio
broadcast: Broadcast broadcast: Broadcast
ingest_node: "Nodo ingest:"
rtmp_ingest: "RTMP ingest:" rtmp_ingest: "RTMP ingest:"
events_title: Eventi events_title: Eventi
table: table:
@@ -298,6 +305,13 @@ it:
active_exists: Esiste già un link regia attivo (lURL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo. active_exists: Esiste già un link regia attivo (lURL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.
none_active: Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio. none_active: Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.
generate_button: Genera link regia generate_button: Genera link regia
ingest:
none: "—"
role:
home: Home-lab
lab: Lab
cloud: Hetzner
billing: billing:
index: index:
title: Pagamenti da fatturare title: Pagamenti da fatturare
@@ -309,6 +323,8 @@ it:
club: Società club: Società
plan: Piano plan: Piano
amount: Importo amount: Importo
holder: Intestatario
iban: IBAN
causal: Causale causal: Causale
requested: Richiesto requested: Richiesto
confirm_button: Conferma pagamento e attiva confirm_button: Conferma pagamento e attiva
@@ -396,3 +412,68 @@ it:
club_recordings: club_recordings:
index: index:
title: "Replay — %{club}" 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:
android: App Android
ios: App 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. Android e iOS sono indipendenti: se il testo deve essere diverso, crea due avvisi. 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 della piattaforma (Play Store su Android, App Store su iOS).
action_label: Etichetta del pulsante
locales_legend: Testo per lingua
locales_hint: Compila tutte le lingue dell'app (italiano, inglese, francese, tedesco, spagnolo). L'italiano è obbligatorio; se una lingua manca, l'app usa inglese e poi italiano.
locale_required: obbligatorio
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
+31 -5
View File
@@ -21,6 +21,10 @@ de:
too_short: "ist zu kurz (mindestens %{count} Zeichen)" too_short: "ist zu kurz (mindestens %{count} Zeichen)"
too_long: "ist zu lang (höchstens %{count} Zeichen)" too_long: "ist zu lang (höchstens %{count} Zeichen)"
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten" 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).
translation_too_long: "Übersetzung %{locale}: ein Feld ist zu lang."
translation_invalid_url: "Übersetzung %{locale}: der Link muss eine http(s)-URL sein."
club: club:
back_to_club: "← Verein" back_to_club: "← Verein"
sport_label: Hauptsportart sport_label: Hauptsportart
@@ -83,11 +87,9 @@ de:
first_team_name_placeholder: "z. B. Under 15" first_team_name_placeholder: "z. B. Under 15"
plan_label: "Starttarif (für den gesamten Verein)" plan_label: "Starttarif (für den gesamten Verein)"
plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream" plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
plan_light: Premium Light plan_light: "Premium Light — 59 €/Jahr"
plan_full: Premium Full plan_full: "Premium Full — 199 €/Jahr"
interval_label: Premium-Abrechnung plan_hint: "Karte, Überweisung oder monatliche Abrechnung wählst du danach auf der Abo-Seite. Ein kostenpflichtiger Tarif wird erst nach der Zahlung aktiv."
interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
submit: Verein erstellen submit: Verein erstellen
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
@@ -193,6 +195,10 @@ de:
contact_trailer: für ein maßgeschneidertes Angebot. contact_trailer: für ein maßgeschneidertes Angebot.
club_payments_link: Zahlungen und Rechnungen des Vereins club_payments_link: Zahlungen und Rechnungen des Vereins
streaming_staff: streaming_staff:
principal_heading: Hauptkonto
principal_body_html: "Das Konto, mit dem der Verein registriert wurde (<strong>%{email}</strong>), ist <strong>für dieses Team vorgesehen</strong>: damit meldest du dich in der App an und startest den Livestream."
principal_score_hint_html: "Für den Punktestand von einem anderen Telefon teile während des Streams den <strong>Regie-Link</strong> (kein zweites Konto nötig)."
extra_staff_heading: Weitere Konten, die übertragen können
self_account_heading: "Dein Konto (%{email})" self_account_heading: "Dein Konto (%{email})"
self_account_hint: "Du kannst die Übertragung aus der App verwalten. Für den Punktestand von einem anderen Telefon teile den <strong>Regie-Link</strong> während des Streams (kein zweites Konto nötig)." self_account_hint: "Du kannst die Übertragung aus der App verwalten. Für den Punktestand von einem anderen Telefon teile den <strong>Regie-Link</strong> während des Streams (kein zweites Konto nötig)."
self_assign_submit: Das mache ich — Übertragungsverantwortlicher self_assign_submit: Das mache ich — Übertragungsverantwortlicher
@@ -489,6 +495,7 @@ de:
modal: modal:
set_won_title: Satz gewonnen set_won_title: Satz gewonnen
confirm: Satz beenden confirm: Satz beenden
confirm_action: Bestätigen
cancel: Abbrechen cancel: Abbrechen
board: board:
next_period: Nächstes Viertel next_period: Nächstes Viertel
@@ -528,6 +535,17 @@ de:
mute_error: Fehler beim Stummschalten mute_error: Fehler beim Stummschalten
close_error: Fehler beim Beenden close_error: Fehler beim Beenden
close_confirm: Übertragung endgültig beenden? close_confirm: Übertragung endgültig beenden?
pause_confirm_title: Livestream pausieren?
pause_confirm_body: Das Cover bleibt on air, bis du fortsetzt.
resume_confirm_title: Livestream fortsetzen?
resume_confirm_body: Die Kamera geht wieder on air statt des Covers.
mute_confirm_title: Audio stummschalten?
mute_confirm_body: Das Mikrofon wird nicht mehr an den Stream gesendet, bis du es wieder aktivierst.
unmute_confirm_title: Audio wieder aktivieren?
unmute_confirm_body: Das Mikrofon ist wieder aktiv im Stream.
advance_period_title: Zum nächsten Abschnitt wechseln?
advance_period_body: Die Anzeigetafel wechselt zum nächsten Abschnitt und der aktuelle Stand wird gespeichert.
stop_confirm_title: Livestream beenden?
link_unavailable: Link nicht verfügbar link_unavailable: Link nicht verfügbar
link_copied: Link kopiert link_copied: Link kopiert
copy_prompt: "Link kopieren:" copy_prompt: "Link kopieren:"
@@ -613,6 +631,7 @@ de:
clubs: clubs:
already_registered: Du hast bereits einen Verein registriert already_registered: Du hast bereits einen Verein registriert
created: Verein und erstes Team erstellt. created: Verein und erstes Team erstellt.
created_complete_subscription: Verein erstellt. Schließe das Abo auf dieser Seite ab.
updated: Verein aktualisiert. updated: Verein aktualisiert.
complete_billing_first: Vervollständige deine Rechnungsdaten, bevor du einen Premium-Tarif aktivierst. complete_billing_first: Vervollständige deine Rechnungsdaten, bevor du einen Premium-Tarif aktivierst.
comped_change_denied: "Dieser Tarif ist ein von Match Live TV verwaltetes kostenloses Abonnement. Um zu einem kostenpflichtigen Abonnement zu wechseln, kontaktiere den Support." comped_change_denied: "Dieser Tarif ist ein von Match Live TV verwaltetes kostenloses Abonnement. Um zu einem kostenpflichtigen Abonnement zu wechseln, kontaktiere den Support."
@@ -685,6 +704,13 @@ de:
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
password_mismatch: Die Passwörter stimmen nicht überein password_mismatch: Die Passwörter stimmen nicht überein
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden. password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
contacts:
sent: Nachricht gesendet. Wir antworten an die angegebene E-Mail-Adresse.
throttled: Du hast zu viele Anfragen gesendet. Bitte versuche es in einer Stunde erneut.
invalid: Prüfe die Pflichtfelder und versuche es erneut.
invalid_email: Gib eine gültige E-Mail-Adresse ein.
invalid_message: Die Nachricht ist zu kurz oder zu lang.
privacy_required: Zum Senden des Formulars musst du die Datenschutzerklärung akzeptieren.
accounts: accounts:
name_required: Der Name ist erforderlich name_required: Der Name ist erforderlich
profile_updated: Profil aktualisiert. profile_updated: Profil aktualisiert.
+41 -5
View File
@@ -16,6 +16,10 @@ en:
too_short: "is too short (minimum is %{count} characters)" too_short: "is too short (minimum is %{count} characters)"
too_long: "is too long (maximum 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" 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).
translation_too_long: "Translation %{locale}: a field is too long."
translation_invalid_url: "Translation %{locale}: the link must be an http(s) URL."
club: club:
back_to_club: "← Club" back_to_club: "← Club"
sport_label: Main sport sport_label: Main sport
@@ -33,6 +37,16 @@ en:
quick_colors: Quick colors quick_colors: Quick colors
header_preview_label: Header preview header_preview_label: Header preview
name_placeholder: Club / team name name_placeholder: Club / team name
cover_legend: Live cover (sponsors)
cover_inherit_hint: Leave blank to use the club cover, otherwise the default Match Live TV cover.
cover_hint: "16:9 image recommended (PNG, JPEG or WebP, max 5 MB). Shown to viewers and YouTube when paused or offline."
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: dashboard:
title: "%{name} — Club" title: "%{name} — Club"
plan_label: "Plan:" plan_label: "Plan:"
@@ -78,11 +92,9 @@ en:
first_team_name_placeholder: "e.g. Under 15" first_team_name_placeholder: "e.g. Under 15"
plan_label: "Starting plan (for the whole club)" plan_label: "Starting plan (for the whole club)"
plan_free: "Free — 1 transmitter account per team, 1 live stream" plan_free: "Free — 1 transmitter account per team, 1 live stream"
plan_light: Premium Light plan_light: "Premium Light — €59/year"
plan_full: Premium Full plan_full: "Premium Full — €199/year"
interval_label: Premium billing plan_hint: "Card, bank transfer or monthly billing are chosen later on the Subscription page. A paid plan activates only after payment."
interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
submit: Create club submit: Create club
billing: billing:
title: "Subscription — %{name}" title: "Subscription — %{name}"
@@ -188,6 +200,10 @@ en:
contact_trailer: for a custom offer. contact_trailer: for a custom offer.
club_payments_link: Club payments and invoices club_payments_link: Club payments and invoices
streaming_staff: streaming_staff:
principal_heading: Main account
principal_body_html: "The account used to register the club (<strong>%{email}</strong>) is the one <strong>designated for this team</strong>: use it in the app to start the live stream."
principal_score_hint_html: "For scoring from another phone, share the <strong>control-room link</strong> during the stream (no second account needed)."
extra_staff_heading: Other accounts that can broadcast
self_account_heading: "Your account (%{email})" self_account_heading: "Your account (%{email})"
self_account_hint: "You can manage streaming from the app. For scoring from another phone, share the <strong>control-room link</strong> during the stream (no second account needed)." self_account_hint: "You can manage streaming from the app. For scoring from another phone, share the <strong>control-room link</strong> during the stream (no second account needed)."
self_assign_submit: I'll do it — broadcast manager self_assign_submit: I'll do it — broadcast manager
@@ -484,6 +500,7 @@ en:
modal: modal:
set_won_title: Set won set_won_title: Set won
confirm: Close set confirm: Close set
confirm_action: Confirm
cancel: Cancel cancel: Cancel
board: board:
next_period: Next quarter next_period: Next quarter
@@ -523,6 +540,17 @@ en:
mute_error: Error muting audio mute_error: Error muting audio
close_error: Error closing close_error: Error closing
close_confirm: Permanently close the live stream? close_confirm: Permanently close the live stream?
pause_confirm_title: Pause the live stream?
pause_confirm_body: The cover slate will stay on air until you resume.
resume_confirm_title: Resume the live stream?
resume_confirm_body: The camera will go back on air instead of the cover slate.
mute_confirm_title: Mute audio?
mute_confirm_body: The microphone will not be sent to the stream until you unmute.
unmute_confirm_title: Unmute audio?
unmute_confirm_body: The microphone will be active on the stream again.
advance_period_title: Advance to the next period?
advance_period_body: The scoreboard will move to the next period and the current period score will be saved.
stop_confirm_title: End the live stream?
link_unavailable: Link not available link_unavailable: Link not available
link_copied: Link copied link_copied: Link copied
copy_prompt: "Copy the link:" copy_prompt: "Copy the link:"
@@ -608,6 +636,7 @@ en:
clubs: clubs:
already_registered: You've already registered a club already_registered: You've already registered a club
created: Club and first team created. created: Club and first team created.
created_complete_subscription: Club created. Complete your subscription on this page.
updated: Club updated. updated: Club updated.
complete_billing_first: Complete your billing details before activating a premium plan. complete_billing_first: Complete your billing details before activating a premium plan.
comped_change_denied: "This plan is a complimentary subscription managed by Match Live TV. To switch to a paid subscription, contact support." comped_change_denied: "This plan is a complimentary subscription managed by Match Live TV. To switch to a paid subscription, contact support."
@@ -680,6 +709,13 @@ en:
password_same_as_current: New password must be different from the current password password_same_as_current: New password must be different from the current password
password_mismatch: Passwords don't match password_mismatch: Passwords don't match
password_updated: Password updated. You can now log in. password_updated: Password updated. You can now log in.
contacts:
sent: Message sent. We will reply to the email you provided.
throttled: You have sent too many requests. Please try again in an hour.
invalid: Check the required fields and try again.
invalid_email: Enter a valid email address.
invalid_message: The message is too short or too long.
privacy_required: To send the form you must accept the privacy notice.
accounts: accounts:
name_required: Name is required name_required: Name is required
profile_updated: Profile updated. profile_updated: Profile updated.
+31 -5
View File
@@ -21,6 +21,10 @@ es:
too_short: "es demasiado corta (mínimo %{count} caracteres)" too_short: "es demasiado corta (mínimo %{count} caracteres)"
too_long: "es demasiado larga (máximo %{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" 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).
translation_too_long: "Traducción %{locale}: un campo es demasiado largo."
translation_invalid_url: "Traducción %{locale}: el enlace debe ser una URL http(s)."
club: club:
back_to_club: "← Club" back_to_club: "← Club"
sport_label: Deporte principal sport_label: Deporte principal
@@ -83,11 +87,9 @@ es:
first_team_name_placeholder: "ej. Sub-15" first_team_name_placeholder: "ej. Sub-15"
plan_label: "Plan inicial (para todo el club)" plan_label: "Plan inicial (para todo el club)"
plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo" plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo"
plan_light: Premium Light plan_light: "Premium Light — 59 €/año"
plan_full: Premium Full plan_full: "Premium Full — 199 €/año"
interval_label: Facturación premium plan_hint: "La tarjeta, la transferencia o la facturación mensual se eligen después, en Abono. El plan de pago se activa solo tras el pago."
interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
submit: Crear club submit: Crear club
billing: billing:
title: "Suscripción — %{name}" title: "Suscripción — %{name}"
@@ -193,6 +195,10 @@ es:
contact_trailer: para una oferta a medida. contact_trailer: para una oferta a medida.
club_payments_link: Pagos y facturas del club club_payments_link: Pagos y facturas del club
streaming_staff: streaming_staff:
principal_heading: Cuenta principal
principal_body_html: "La cuenta con la que se registró el club (<strong>%{email}</strong>) es la <strong>designada para este equipo</strong>: con ella entras en la app y arrancas el directo."
principal_score_hint_html: "Para el marcador desde otro teléfono, durante el directo comparte el <strong>enlace de control</strong> (no hace falta una segunda cuenta)."
extra_staff_heading: Otras cuentas que pueden transmitir
self_account_heading: "Tu cuenta (%{email})" self_account_heading: "Tu cuenta (%{email})"
self_account_hint: "Puedes gestionar la transmisión desde la app. Para el marcador desde otro teléfono, comparte el <strong>enlace de control</strong> durante el directo (no hace falta una segunda cuenta)." self_account_hint: "Puedes gestionar la transmisión desde la app. Para el marcador desde otro teléfono, comparte el <strong>enlace de control</strong> durante el directo (no hace falta una segunda cuenta)."
self_assign_submit: Yo me encargo — responsable de transmisión self_assign_submit: Yo me encargo — responsable de transmisión
@@ -489,6 +495,7 @@ es:
modal: modal:
set_won_title: Set ganado set_won_title: Set ganado
confirm: Cerrar set confirm: Cerrar set
confirm_action: Confirmar
cancel: Cancelar cancel: Cancelar
board: board:
next_period: Siguiente cuarto next_period: Siguiente cuarto
@@ -528,6 +535,17 @@ es:
mute_error: Error al silenciar el audio mute_error: Error al silenciar el audio
close_error: Error al cerrar close_error: Error al cerrar
close_confirm: "¿Cerrar definitivamente la retransmisión?" close_confirm: "¿Cerrar definitivamente la retransmisión?"
pause_confirm_title: "¿Pausar el directo?"
pause_confirm_body: La portada seguirá en antena hasta que lo reanudes.
resume_confirm_title: "¿Reanudar el directo?"
resume_confirm_body: La cámara volverá a antena en lugar de la portada.
mute_confirm_title: "¿Silenciar el audio?"
mute_confirm_body: El micrófono no se enviará al stream hasta que lo reactives.
unmute_confirm_title: "¿Reactivar el audio?"
unmute_confirm_body: El micrófono volverá a estar activo en el stream.
advance_period_title: "¿Pasar al periodo siguiente?"
advance_period_body: El marcador pasará al siguiente periodo y se guardará el marcador del periodo actual.
stop_confirm_title: "¿Terminar el directo?"
link_unavailable: Enlace no disponible link_unavailable: Enlace no disponible
link_copied: Enlace copiado link_copied: Enlace copiado
copy_prompt: "Copia el enlace:" copy_prompt: "Copia el enlace:"
@@ -613,6 +631,7 @@ es:
clubs: clubs:
already_registered: Ya has registrado un club already_registered: Ya has registrado un club
created: Club y primer equipo creados. created: Club y primer equipo creados.
created_complete_subscription: Club creado. Completa la suscripción en esta página.
updated: Club actualizado. updated: Club actualizado.
complete_billing_first: Completa tus datos de facturación antes de activar un plan premium. complete_billing_first: Completa tus datos de facturación antes de activar un plan premium.
comped_change_denied: "Este plan es una suscripción de cortesía gestionada por Match Live TV. Para pasar a una suscripción de pago, contacta con soporte." comped_change_denied: "Este plan es una suscripción de cortesía gestionada por Match Live TV. Para pasar a una suscripción de pago, contacta con soporte."
@@ -685,6 +704,13 @@ es:
password_same_as_current: La nueva contraseña debe ser distinta de la actual password_same_as_current: La nueva contraseña debe ser distinta de la actual
password_mismatch: Las contraseñas no coinciden password_mismatch: Las contraseñas no coinciden
password_updated: Contraseña actualizada. Ya puedes iniciar sesión. password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
contacts:
sent: Mensaje enviado. Te responderemos al email indicado.
throttled: Has enviado demasiadas solicitudes. Inténtalo de nuevo dentro de una hora.
invalid: Revisa los campos obligatorios e inténtalo de nuevo.
invalid_email: Introduce una dirección de email válida.
invalid_message: El mensaje es demasiado corto o demasiado largo.
privacy_required: Para enviar el formulario debes aceptar la política de privacidad.
accounts: accounts:
name_required: El nombre es obligatorio name_required: El nombre es obligatorio
profile_updated: Perfil actualizado. profile_updated: Perfil actualizado.
+31 -5
View File
@@ -21,6 +21,10 @@ fr:
too_short: "est trop court (minimum %{count} caractères)" too_short: "est trop court (minimum %{count} caractères)"
too_long: "est trop long (maximum %{count} caractères)" too_long: "est trop long (maximum %{count} caractères)"
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles" 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é).
translation_too_long: "Traduction %{locale} : un champ est trop long."
translation_invalid_url: "Traduction %{locale} : le lien doit être une URL http(s)."
club: club:
back_to_club: "← Club" back_to_club: "← Club"
sport_label: Sport principal sport_label: Sport principal
@@ -83,11 +87,9 @@ fr:
first_team_name_placeholder: "ex. Under 15" first_team_name_placeholder: "ex. Under 15"
plan_label: "Forfait de départ (pour tout le club)" plan_label: "Forfait de départ (pour tout le club)"
plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct" plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
plan_light: Premium Light plan_light: "Premium Light — 59 €/an"
plan_full: Premium Full plan_full: "Premium Full — 199 €/an"
interval_label: Facturation premium plan_hint: "Carte, virement ou mensualité se choisissent ensuite, sur la page Abonnement. Le forfait payant ne s'active qu'après le paiement."
interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
submit: Créer le club submit: Créer le club
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
@@ -193,6 +195,10 @@ fr:
contact_trailer: pour une offre sur mesure. contact_trailer: pour une offre sur mesure.
club_payments_link: Paiements et factures du club club_payments_link: Paiements et factures du club
streaming_staff: streaming_staff:
principal_heading: Compte principal
principal_body_html: "Le compte utilisé pour enregistrer le club (<strong>%{email}</strong>) est celui <strong>désigné pour cette équipe</strong> : cest avec lui que tu te connectes à lapp et que tu lances le direct."
principal_score_hint_html: "Pour le score depuis un autre téléphone, pendant le direct partage le <strong>lien régie</strong> (pas besoin dun second compte)."
extra_staff_heading: Autres comptes autorisés à diffuser
self_account_heading: "Ton compte (%{email})" self_account_heading: "Ton compte (%{email})"
self_account_hint: "Tu peux gérer la diffusion depuis l'application. Pour le score depuis un autre téléphone, partage le <strong>lien régie</strong> pendant le direct (pas besoin d'un second compte)." self_account_hint: "Tu peux gérer la diffusion depuis l'application. Pour le score depuis un autre téléphone, partage le <strong>lien régie</strong> pendant le direct (pas besoin d'un second compte)."
self_assign_submit: C'est moi — responsable diffusion self_assign_submit: C'est moi — responsable diffusion
@@ -489,6 +495,7 @@ fr:
modal: modal:
set_won_title: Set gagné set_won_title: Set gagné
confirm: Terminer le set confirm: Terminer le set
confirm_action: Confirmer
cancel: Annuler cancel: Annuler
board: board:
next_period: Quart-temps suivant next_period: Quart-temps suivant
@@ -528,6 +535,17 @@ fr:
mute_error: Erreur lors de la coupure audio mute_error: Erreur lors de la coupure audio
close_error: Erreur de clôture close_error: Erreur de clôture
close_confirm: Clôturer définitivement le direct ? close_confirm: Clôturer définitivement le direct ?
pause_confirm_title: Mettre le direct en pause ?
pause_confirm_body: La couverture restera à l'antenne jusqu'à la reprise.
resume_confirm_title: Reprendre le direct ?
resume_confirm_body: La caméra reviendra à l'antenne à la place de la couverture.
mute_confirm_title: Couper l'audio ?
mute_confirm_body: Le micro ne sera plus envoyé au flux tant que vous ne le réactivez pas.
unmute_confirm_title: Réactiver l'audio ?
unmute_confirm_body: Le micro sera à nouveau actif sur le flux.
advance_period_title: Passer à la période suivante ?
advance_period_body: Le tableau passera à la période suivante et le score de la période en cours sera enregistré.
stop_confirm_title: Terminer le direct ?
link_unavailable: Lien non disponible link_unavailable: Lien non disponible
link_copied: Lien copié link_copied: Lien copié
copy_prompt: "Copiez le lien :" copy_prompt: "Copiez le lien :"
@@ -613,6 +631,7 @@ fr:
clubs: clubs:
already_registered: Tu as déjà inscrit un club already_registered: Tu as déjà inscrit un club
created: Club et première équipe créés. created: Club et première équipe créés.
created_complete_subscription: Club créé. Termine l'abonnement sur cette page.
updated: Club mis à jour. updated: Club mis à jour.
complete_billing_first: Complète tes données de facturation avant d'activer un forfait premium. complete_billing_first: Complète tes données de facturation avant d'activer un forfait premium.
comped_change_denied: "Ce forfait est un abonnement offert géré par Match Live TV. Pour passer à un abonnement payant, contacte le support." comped_change_denied: "Ce forfait est un abonnement offert géré par Match Live TV. Pour passer à un abonnement payant, contacte le support."
@@ -685,6 +704,13 @@ fr:
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel" password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
password_mismatch: Les mots de passe ne correspondent pas password_mismatch: Les mots de passe ne correspondent pas
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter. password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
contacts:
sent: Message envoyé. Nous te répondrons à l'email indiqué.
throttled: Tu as envoyé trop de demandes. Réessaie dans une heure.
invalid: Vérifie les champs obligatoires et réessaie.
invalid_email: Saisis une adresse email valide.
invalid_message: Le message est trop court ou trop long.
privacy_required: Pour envoyer le formulaire tu dois accepter la notice de confidentialité.
accounts: accounts:
name_required: Le nom est obligatoire name_required: Le nom est obligatoire
profile_updated: Profil mis à jour. profile_updated: Profil mis à jour.
+48 -10
View File
@@ -16,6 +16,10 @@ it:
too_short: "è troppo corta (minimo %{count} caratteri)" too_short: "è troppo corta (minimo %{count} caratteri)"
too_long: "è troppo lunga (massimo %{count} caratteri)" too_long: "è troppo lunga (massimo %{count} caratteri)"
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli" 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).
translation_too_long: "Traduzione %{locale}: un campo supera la lunghezza massima."
translation_invalid_url: "Traduzione %{locale}: il link deve essere un URL http(s)."
club: club:
back_to_club: "← Società" back_to_club: "← Società"
sport_label: Sport principale sport_label: Sport principale
@@ -33,6 +37,16 @@ it:
quick_colors: Colori rapidi quick_colors: Colori rapidi
header_preview_label: Anteprima intestazione header_preview_label: Anteprima intestazione
name_placeholder: Nome società / squadra name_placeholder: Nome società / squadra
cover_legend: Copertina diretta (sponsor)
cover_inherit_hint: Lascia vuoto per usare la copertina della società, altrimenti quella standard Match Live TV.
cover_hint: "Immagine 16:9 consigliata (PNG, JPEG o WebP, max 5 MB). In pausa o senza segnale la vedranno spettatori e YouTube."
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: dashboard:
title: "%{name} — Società" title: "%{name} — Società"
plan_label: "Piano:" plan_label: "Piano:"
@@ -78,11 +92,9 @@ it:
first_team_name_placeholder: "es. Under 15" first_team_name_placeholder: "es. Under 15"
plan_label: "Piano iniziale (per tutta la società)" plan_label: "Piano iniziale (per tutta la società)"
plan_free: "Free — 1 account trasmettitore per squadra, 1 live" plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
plan_light: Premium Light plan_light: "Premium Light — €59/anno"
plan_full: Premium Full plan_full: "Premium Full — €199/anno"
interval_label: Fatturazione premium plan_hint: "Carta, bonifico o cadenza mensile li scegli dopo, nella pagina Abbonamento. Il piano a pagamento si attiva solo dopo il pagamento."
interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
submit: Crea società submit: Crea società
billing: billing:
title: "Abbonamento — %{name}" title: "Abbonamento — %{name}"
@@ -188,6 +200,10 @@ it:
contact_trailer: per un'offerta su misura. contact_trailer: per un'offerta su misura.
club_payments_link: Pagamenti e fatture della società club_payments_link: Pagamenti e fatture della società
streaming_staff: streaming_staff:
principal_heading: Account principale
principal_body_html: "L'account con cui è stata registrata la società (<strong>%{email}</strong>) è quello <strong>designato per questa squadra</strong>: con quello accedi all'app e avvii la diretta."
principal_score_hint_html: "Per il punteggio da un altro telefono, durante la diretta condividi il <strong>link regia</strong> (non serve un secondo account)."
extra_staff_heading: Altri account che possono trasmettere
self_account_heading: "Il tuo account (%{email})" self_account_heading: "Il tuo account (%{email})"
self_account_hint: "Puoi gestire la trasmissione dall'app. Per il punteggio da un altro telefono, condividi il <strong>link regia</strong> durante la diretta (non serve un secondo account)." self_account_hint: "Puoi gestire la trasmissione dall'app. Per il punteggio da un altro telefono, condividi il <strong>link regia</strong> durante la diretta (non serve un secondo account)."
self_assign_submit: Sono io — responsabile trasmissione self_assign_submit: Sono io — responsabile trasmissione
@@ -330,7 +346,7 @@ it:
sdi_or_pec: "Codice destinatario SDI (7 caratteri) o PEC" sdi_or_pec: "Codice destinatario SDI (7 caratteri) o PEC"
sdi_invalid: "Codice destinatario SDI (7 caratteri)" sdi_invalid: "Codice destinatario SDI (7 caratteri)"
actions: actions:
current_plan: Piano attuale current_plan: Piano attivo
contact_for_free: "Per passare al piano Free, contatta il supporto." contact_for_free: "Per passare al piano Free, contatta il supporto."
stripe_not_configured: Stripe non configurato stripe_not_configured: Stripe non configurato
stripe_prices_not_configured: Prezzi Stripe non configurati stripe_prices_not_configured: Prezzi Stripe non configurati
@@ -436,13 +452,15 @@ it:
pay_button: Paga con bonifico pay_button: Paga con bonifico
pay_button_with_price: "Paga con bonifico — %{price}" pay_button_with_price: "Paga con bonifico — %{price}"
or_label: oppure or_label: oppure
pending_title: Bonifico in attesa di conferma pending_title: Ordine in corso
pending_body_html: "Hai richiesto <strong>%{plan}</strong> (%{price}). Il piano si attiverà dopo la conferma del pagamento. Causale: <strong>%{causal}</strong>." pending_body_html: "Hai richiesto <strong>%{plan}</strong> (%{price}). Resta in attesa: il piano si attiverà quando Match Live TV confermerà il pagamento."
pending_proof_html: "Invia la distinta a %{email_link}."
quote_banner_title: Prezzo concordato quote_banner_title: Prezzo concordato
quote_banner_body_html: "Per questa società vale <strong>%{plan}</strong> a <strong>%{price}</strong>. Il pagamento avviene solo con bonifico (non con carta)." quote_banner_body_html: "Per questa società vale <strong>%{plan}</strong> a <strong>%{price}</strong>. Il pagamento avviene solo con bonifico (non con carta)."
quote_banner_active_title: Piano attivo
quote_banner_active_body_html: "In questo momento vale <strong>%{plan}</strong> a <strong>%{price}</strong>, fino al <strong>%{date}</strong>. Il rinnovo non è automatico."
quote_note: "Nota commerciale: %{note}" quote_note: "Nota commerciale: %{note}"
hint: "Il piano resta quello attuale finché Match Live TV non conferma l'accredito." hint: "Il piano resta quello attuale finché Match Live TV non conferma l'accredito."
confirm: "Confermi la richiesta di bonifico? Il piano resta quello attuale finché Match Live TV non conferma l'accredito."
renewal_html: "Con il bonifico l'abbonamento non si rinnova da solo: alla scadenza torna Free, salvo nuovo bonifico o rinnovo da parte nostra." renewal_html: "Con il bonifico l'abbonamento non si rinnova da solo: alla scadenza torna Free, salvo nuovo bonifico o rinnovo da parte nostra."
regia: regia:
meta_title_fallback: "Regia — Match Live TV" meta_title_fallback: "Regia — Match Live TV"
@@ -503,6 +521,7 @@ it:
modal: modal:
set_won_title: Set vinto set_won_title: Set vinto
confirm: Chiudi set confirm: Chiudi set
confirm_action: Conferma
cancel: Annulla cancel: Annulla
board: board:
next_period: Prossimo quarto next_period: Prossimo quarto
@@ -542,6 +561,17 @@ it:
mute_error: Errore silenziamento audio mute_error: Errore silenziamento audio
close_error: Errore chiusura close_error: Errore chiusura
close_confirm: Chiudere definitivamente la diretta? close_confirm: Chiudere definitivamente la diretta?
pause_confirm_title: Mettere in pausa?
pause_confirm_body: La diretta passerà alla copertina finché non la riprendi.
resume_confirm_title: Riprendere la diretta?
resume_confirm_body: La telecamera tornerà in onda al posto della copertina.
mute_confirm_title: Silenziare l'audio?
mute_confirm_body: Il microfono non verrà inviato allo stream finché non lo riattivi.
unmute_confirm_title: Riattivare l'audio?
unmute_confirm_body: Il microfono tornerà attivo sullo stream.
advance_period_title: Passare al periodo successivo?
advance_period_body: Il tabellone passerà al periodo successivo e il punteggio del periodo corrente sarà salvato.
stop_confirm_title: Terminare la diretta?
link_unavailable: Link non disponibile link_unavailable: Link non disponibile
link_copied: Link copiato link_copied: Link copiato
copy_prompt: "Copia il link:" copy_prompt: "Copia il link:"
@@ -627,6 +657,7 @@ it:
clubs: clubs:
already_registered: Hai già registrato una società already_registered: Hai già registrato una società
created: Società e prima squadra create. created: Società e prima squadra create.
created_complete_subscription: Società creata. Completa l'abbonamento in questa pagina.
updated: Società aggiornata. updated: Società aggiornata.
complete_billing_first: Completa i dati di fatturazione prima di attivare un piano premium. complete_billing_first: Completa i dati di fatturazione prima di attivare un piano premium.
comped_change_denied: "Il piano è un abbonamento omaggio gestito da Match Live TV. Per passare a un abbonamento a pagamento contatta il supporto." comped_change_denied: "Il piano è un abbonamento omaggio gestito da Match Live TV. Per passare a un abbonamento a pagamento contatta il supporto."
@@ -646,7 +677,7 @@ it:
subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free." subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free."
stripe_error: "Errore Stripe: %{message}" stripe_error: "Errore Stripe: %{message}"
invoice_pdf_unavailable: PDF fattura non disponibile. invoice_pdf_unavailable: PDF fattura non disponibile.
bank_transfer_requested: Istruzioni per il bonifico inviate all'email di fatturazione. Il piano si attiverà dopo la conferma del pagamento. bank_transfer_requested: Richiesta registrata. Il piano si attiverà dopo la conferma del pagamento.
quote_checkout_denied: "Per questa società è attivo un prezzo concordato: usa il pagamento con bonifico." quote_checkout_denied: "Per questa società è attivo un prezzo concordato: usa il pagamento con bonifico."
club_recordings: club_recordings:
not_authorized: Non autorizzato not_authorized: Non autorizzato
@@ -704,6 +735,13 @@ it:
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli" password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
password_mismatch: Le password non coincidono password_mismatch: Le password non coincidono
password_updated: Password aggiornata. Ora puoi accedere. password_updated: Password aggiornata. Ora puoi accedere.
contacts:
sent: Messaggio inviato. Ti rispondiamo all'email indicata.
throttled: Hai inviato troppe richieste. Riprova tra un'ora.
invalid: Controlla i campi obbligatori e riprova.
invalid_email: Inserisci un indirizzo email valido.
invalid_message: Il messaggio è troppo corto o troppo lungo.
privacy_required: Per inviare il modulo devi accettare l'informativa privacy.
accounts: accounts:
name_required: Il nome è obbligatorio name_required: Il nome è obbligatorio
profile_updated: Profilo aggiornato. profile_updated: Profilo aggiornato.
+5
View File
@@ -0,0 +1,5 @@
en:
coverable:
errors:
invalid_type: "must be PNG, JPEG or WebP"
too_large: "exceeds the maximum size of 5 MB"
+5
View File
@@ -0,0 +1,5 @@
it:
coverable:
errors:
invalid_type: "deve essere PNG, JPEG o WebP"
too_large: "supera la dimensione massima di 5 MB"
+4
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_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_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_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_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_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 s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
@@ -56,6 +57,8 @@ de:
s5_row5_basis: Vertragserfüllung s5_row5_basis: Vertragserfüllung
s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung) s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung)
s5_row6_basis: Einwilligung oder berechtigtes Interesse, je nach Fall s5_row6_basis: Einwilligung oder berechtigtes Interesse, je nach Fall
s5_row7_purpose: Bearbeitung von Anfragen über das Kontaktformular
s5_row7_basis: Vorvertragliche Maßnahmen; berechtigtes Interesse an der Beantwortung von Anfragen
s6_title: 6. Empfänger und Auftragsverarbeiter s6_title: 6. Empfänger und Auftragsverarbeiter
s6_intro: "Die Daten können an Dienstleister weitergegeben werden, die als Auftragsverarbeiter tätig sind, unter anderem:" s6_intro: "Die Daten können an Dienstleister weitergegeben werden, die als Auftragsverarbeiter tätig sind, unter anderem:"
s6_item1: Anbieter von Hosting- und Cloud-/Serverinfrastruktur; s6_item1: Anbieter von Hosting- und Cloud-/Serverinfrastruktur;
@@ -72,6 +75,7 @@ de:
s8_item2_html: "<strong>Spielaufzeichnungen (Replays):</strong> je nach Paket (z. B. 30 oder 90 Tage), danach automatische Löschung oder Löschung auf Anfrage." s8_item2_html: "<strong>Spielaufzeichnungen (Replays):</strong> je nach Paket (z. B. 30 oder 90 Tage), danach automatische Löschung oder Löschung auf Anfrage."
s8_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 3090 Tage), sofern keine Sicherheitspflichten entgegenstehen." s8_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 3090 Tage), sofern keine Sicherheitspflichten entgegenstehen."
s8_item4_html: "<strong>Token zum Zurücksetzen des Passworts:</strong> maximale Gültigkeit %{hours} Stunden." s8_item4_html: "<strong>Token zum Zurücksetzen des Passworts:</strong> maximale Gültigkeit %{hours} Stunden."
s8_item5_html: "<strong>Anfragen über das Kontaktformular:</strong> nur so lange gespeichert, wie zur Bearbeitung der Anfrage erforderlich, danach aus dem betrieblichen Postfach gelöscht."
s9_title: 9. Rechte der betroffenen Personen s9_title: 9. Rechte der betroffenen Personen
s9_intro: "Als betroffene Person haben Sie das Recht:" s9_intro: "Als betroffene Person haben Sie das Recht:"
s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie; s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie;
+4
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_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_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_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_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_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 s4_lead1: Who is responsible for processing images of minors
@@ -56,6 +57,8 @@ en:
s5_row5_basis: Performance of a contract s5_row5_basis: Performance of a contract
s5_row6_purpose: Direct marketing (only where applicable and with consent where required) 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_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_title: 6. Recipients and data processors
s6_intro: "Data may be shared with providers acting as data processors, including:" s6_intro: "Data may be shared with providers acting as data processors, including:"
s6_item1: hosting and cloud/server infrastructure provider; s6_item1: hosting and cloud/server infrastructure provider;
@@ -72,6 +75,7 @@ en:
s8_item2_html: "<strong>Match recordings (replays):</strong> according to the plan (e.g. 30 or 90 days), then automatic deletion or deletion on request." s8_item2_html: "<strong>Match recordings (replays):</strong> according to the plan (e.g. 30 or 90 days), then automatic deletion or deletion on request."
s8_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 3090 days), unless security obligations require otherwise." s8_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 3090 days), unless security obligations require otherwise."
s8_item4_html: "<strong>Password reset token:</strong> maximum validity %{hours} hours." s8_item4_html: "<strong>Password reset token:</strong> maximum validity %{hours} hours."
s8_item5_html: "<strong>Contact form enquiries:</strong> kept only for as long as needed to handle the request, then deleted from the operational mailbox."
s9_title: 9. Data subject rights s9_title: 9. Data subject rights
s9_intro: "As a data subject you have the right to:" s9_intro: "As a data subject you have the right to:"
s9_item1: access your data and obtain a copy of it; s9_item1: access your data and obtain a copy of it;
+4
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_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_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_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_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_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 s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
@@ -56,6 +57,8 @@ es:
s5_row5_basis: Ejecución del contrato s5_row5_basis: Ejecución del contrato
s5_row6_purpose: Marketing directo (solo si está previsto y con consentimiento cuando se requiera) s5_row6_purpose: Marketing directo (solo si está previsto y con consentimiento cuando se requiera)
s5_row6_basis: Consentimiento o interés legítimo, según los casos s5_row6_basis: Consentimiento o interés legítimo, según los casos
s5_row7_purpose: Gestión de las solicitudes enviadas desde el formulario de contacto
s5_row7_basis: Medidas precontractuales; interés legítimo en responder a las solicitudes
s6_title: 6. Destinatarios y encargados del tratamiento s6_title: 6. Destinatarios y encargados del tratamiento
s6_intro: "Los datos pueden comunicarse a proveedores que actúan como encargados del tratamiento, entre ellos:" s6_intro: "Los datos pueden comunicarse a proveedores que actúan como encargados del tratamiento, entre ellos:"
s6_item1: proveedor de alojamiento e infraestructura en la nube/servidor; s6_item1: proveedor de alojamiento e infraestructura en la nube/servidor;
@@ -72,6 +75,7 @@ es:
s8_item2_html: "<strong>Grabaciones de partidos (repeticiones):</strong> según el plan (por ejemplo, 30 o 90 días), después eliminación automática o a petición." s8_item2_html: "<strong>Grabaciones de partidos (repeticiones):</strong> según el plan (por ejemplo, 30 o 90 días), después eliminación automática o a petición."
s8_item3_html: "<strong>Registros técnicos:</strong> periodo limitado (por ejemplo, 30-90 días) salvo obligaciones de seguridad." s8_item3_html: "<strong>Registros técnicos:</strong> periodo limitado (por ejemplo, 30-90 días) salvo obligaciones de seguridad."
s8_item4_html: "<strong>Token de restablecimiento de contraseña:</strong> validez máxima %{hours} horas." s8_item4_html: "<strong>Token de restablecimiento de contraseña:</strong> validez máxima %{hours} horas."
s8_item5_html: "<strong>Solicitudes del formulario de contacto:</strong> se conservan solo el tiempo necesario para gestionar la solicitud y luego se eliminan del buzón operativo."
s9_title: 9. Derechos de los interesados s9_title: 9. Derechos de los interesados
s9_intro: "Como interesado tienes derecho a:" s9_intro: "Como interesado tienes derecho a:"
s9_item1: acceder a tus datos y obtener una copia de ellos; s9_item1: acceder a tus datos y obtener una copia de ellos;
+4
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_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_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_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_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_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 s4_lead1: Qui est responsable du traitement des images des mineurs
@@ -56,6 +57,8 @@ fr:
s5_row5_basis: Exécution du contrat s5_row5_basis: Exécution du contrat
s5_row6_purpose: Marketing direct (uniquement si prévu et avec consentement le cas échéant) s5_row6_purpose: Marketing direct (uniquement si prévu et avec consentement le cas échéant)
s5_row6_basis: Consentement ou intérêt légitime, selon les cas s5_row6_basis: Consentement ou intérêt légitime, selon les cas
s5_row7_purpose: Traitement des demandes envoyées via le formulaire de contact
s5_row7_basis: Mesures précontractuelles ; intérêt légitime à répondre aux demandes
s6_title: 6. Destinataires et sous-traitants s6_title: 6. Destinataires et sous-traitants
s6_intro: "Les données peuvent être communiquées à des prestataires agissant en tant que sous-traitants, notamment :" s6_intro: "Les données peuvent être communiquées à des prestataires agissant en tant que sous-traitants, notamment :"
s6_item1: fournisseur d'hébergement et d'infrastructure cloud/serveur ; s6_item1: fournisseur d'hébergement et d'infrastructure cloud/serveur ;
@@ -72,6 +75,7 @@ fr:
s8_item2_html: "<strong>Enregistrements des matchs (replays) :</strong> selon le forfait (par ex. 30 ou 90 jours), puis suppression automatique ou sur demande." s8_item2_html: "<strong>Enregistrements des matchs (replays) :</strong> selon le forfait (par ex. 30 ou 90 jours), puis suppression automatique ou sur demande."
s8_item3_html: "<strong>Journaux techniques :</strong> durée limitée (par ex. 30 à 90 jours) sauf obligations de sécurité." s8_item3_html: "<strong>Journaux techniques :</strong> durée limitée (par ex. 30 à 90 jours) sauf obligations de sécurité."
s8_item4_html: "<strong>Jeton de réinitialisation du mot de passe :</strong> validité maximale %{hours} heures." s8_item4_html: "<strong>Jeton de réinitialisation du mot de passe :</strong> validité maximale %{hours} heures."
s8_item5_html: "<strong>Demandes du formulaire de contact :</strong> conservées uniquement le temps nécessaire au traitement de la demande, puis supprimées de la boîte mail opérationnelle."
s9_title: 9. Droits des personnes concernées s9_title: 9. Droits des personnes concernées
s9_intro: "En tant que personne concernée, vous avez le droit :" s9_intro: "En tant que personne concernée, vous avez le droit :"
s9_item1: d'accéder à vos données et d'en obtenir une copie ; s9_item1: d'accéder à vos données et d'en obtenir une copie ;
+4
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_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_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_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_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_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 s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
@@ -56,6 +57,8 @@ it:
s5_row5_basis: Esecuzione del contratto s5_row5_basis: Esecuzione del contratto
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto) s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
s5_row6_basis: Consenso o legittimo interesse, secondo i casi 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_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_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; s6_item1: fornitore di hosting e infrastruttura cloud/server;
@@ -72,6 +75,7 @@ it:
s8_item2_html: "<strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta." s8_item2_html: "<strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta."
s8_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 3090 giorni) salvo obblighi di sicurezza." s8_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 3090 giorni) salvo obblighi di sicurezza."
s8_item4_html: "<strong>Token reset password:</strong> validità massima %{hours} ore." s8_item4_html: "<strong>Token reset password:</strong> validità massima %{hours} ore."
s8_item5_html: "<strong>Richieste dal modulo contatti:</strong> conservate solo per il tempo necessario a gestire la richiesta, poi eliminate dalla casella di posta operativa."
s9_title: 9. Diritti degli interessati s9_title: 9. Diritti degli interessati
s9_intro: "In qualità di interessato hai diritto di:" s9_intro: "In qualità di interessato hai diritto di:"
s9_item1: accedere ai tuoi dati e ottenerne copia; s9_item1: accedere ai tuoi dati e ottenerne copia;
+17
View File
@@ -9,6 +9,23 @@ de:
expiry_html: "Der Link ist %{hours} Stunden gültig. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail — dein Passwort wird nicht geändert." expiry_html: "Der Link ist %{hours} Stunden gültig. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail — dein Passwort wird nicht geändert."
ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail. ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Vertriebsanfrage — %{name}"
support: "Supportanfrage — %{name}"
privacy: "Datenschutzanfrage — %{name}"
heading: Neue Anfrage von der Website
name: "Name:"
email: "E-Mail:"
club: "Verein:"
club_none: Nicht angegeben
topic: "Anliegen:"
topic_labels:
commercial: Vertrieb / Angebot
support: Technischer Support
privacy: Datenschutz / DSGVO
message: "Nachricht:"
footer: Match Live TV — Kontaktseite
replay_ready: replay_ready:
subject: "Replay bereit — %{title}" subject: "Replay bereit — %{title}"
hello: "Hallo %{name}," hello: "Hallo %{name},"
+17
View File
@@ -9,6 +9,23 @@ en:
expiry_html: "The link is valid for %{hours} hours. If you did not request this reset, ignore this email — your password will not change." expiry_html: "The link is valid for %{hours} hours. If you did not request this reset, ignore this email — your password will not change."
ignore: If you did not request this reset, ignore this email. ignore: If you did not request this reset, ignore this email.
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Sales enquiry — %{name}"
support: "Support enquiry — %{name}"
privacy: "Privacy enquiry — %{name}"
heading: New website enquiry
name: "Name:"
email: "Email:"
club: "Club:"
club_none: Not provided
topic: "Topic:"
topic_labels:
commercial: Sales / quote
support: Technical support
privacy: Privacy / GDPR
message: "Message:"
footer: Match Live TV — Contact page
replay_ready: replay_ready:
subject: "Replay ready — %{title}" subject: "Replay ready — %{title}"
hello: "Hi %{name}," hello: "Hi %{name},"
+17
View File
@@ -9,6 +9,23 @@ es:
expiry_html: "El enlace es válido durante %{hours} horas. Si no solicitaste el restablecimiento, ignora este correo: la contraseña no se modificará." expiry_html: "El enlace es válido durante %{hours} horas. Si no solicitaste el restablecimiento, ignora este correo: la contraseña no se modificará."
ignore: Si no solicitaste el restablecimiento, ignora este correo. ignore: Si no solicitaste el restablecimiento, ignora este correo.
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Consulta comercial — %{name}"
support: "Consulta de asistencia — %{name}"
privacy: "Consulta de privacidad — %{name}"
heading: Nueva solicitud desde el sitio
name: "Nombre:"
email: "Email:"
club: "Club:"
club_none: No indicado
topic: "Motivo:"
topic_labels:
commercial: Comercial / presupuesto
support: Asistencia técnica
privacy: Privacidad / RGPD
message: "Mensaje:"
footer: Match Live TV — página de Contacto
replay_ready: replay_ready:
subject: "Replay listo — %{title}" subject: "Replay listo — %{title}"
hello: "Hola %{name}," hello: "Hola %{name},"
+17
View File
@@ -9,6 +9,23 @@ fr:
expiry_html: "Le lien est valable %{hours} heures. Si vous navez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié." expiry_html: "Le lien est valable %{hours} heures. Si vous navez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié."
ignore: Si vous navez pas demandé cette réinitialisation, ignorez cet e-mail. ignore: Si vous navez pas demandé cette réinitialisation, ignorez cet e-mail.
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Demande commerciale — %{name}"
support: "Demande d'assistance — %{name}"
privacy: "Demande privacy — %{name}"
heading: Nouvelle demande depuis le site
name: "Nom :"
email: "Email :"
club: "Club :"
club_none: Non indiqué
topic: "Motif :"
topic_labels:
commercial: Commercial / devis
support: Assistance technique
privacy: Confidentialité / RGPD
message: "Message :"
footer: Match Live TV — page Contact
replay_ready: replay_ready:
subject: "Replay prêt — %{title}" subject: "Replay prêt — %{title}"
hello: "Bonjour %{name}," hello: "Bonjour %{name},"
+17
View File
@@ -9,6 +9,23 @@ it:
expiry_html: "Il link è valido per %{hours} ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata." expiry_html: "Il link è valido per %{hours} ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata."
ignore: Se non hai richiesto tu il reset, ignora questa email. ignore: Se non hai richiesto tu il reset, ignora questa email.
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
contact:
subject:
commercial: "Richiesta commerciale — %{name}"
support: "Richiesta assistenza — %{name}"
privacy: "Richiesta privacy — %{name}"
heading: Nuova richiesta dal sito
name: "Nome:"
email: "Email:"
club: "Società:"
club_none: Non indicata
topic: "Motivo:"
topic_labels:
commercial: Commerciale / preventivo
support: Assistenza tecnica
privacy: Privacy / GDPR
message: "Messaggio:"
footer: Match Live TV — pagina Contatti
replay_ready: replay_ready:
subject: "Replay pronto — %{title}" subject: "Replay pronto — %{title}"
hello: "Ciao %{name}," hello: "Ciao %{name},"
+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." 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_start: Kostenlos starten
cta_faq: Häufige Fragen 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." 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_start: Start for free
cta_faq: Frequently asked questions 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." 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_start: Empieza gratis
cta_faq: Preguntas frecuentes 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

Some files were not shown because too many files have changed in this diff Show More