Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43d5003de8 | ||
|
|
eadfc29da3 | ||
|
|
50d3e2cf16 | ||
|
|
55e71ca838 | ||
|
|
5b723bf844 | ||
|
|
e502844b88 | ||
|
|
0702cb699c | ||
|
|
e09b3ffcf8 | ||
|
|
41d4235258 | ||
|
|
a51d5e8da5 | ||
|
|
5bcb4170c7 |
@@ -0,0 +1,69 @@
|
||||
module Admin
|
||||
class AnnouncementsController < Admin::BaseController
|
||||
before_action :set_announcement, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@announcements = AppAnnouncement.newest
|
||||
end
|
||||
|
||||
def new
|
||||
@announcement = AppAnnouncement.new(
|
||||
kind: "info",
|
||||
severity: "info",
|
||||
status: "draft",
|
||||
show_on_app: true,
|
||||
show_on_web_public: true,
|
||||
show_on_web_private: true,
|
||||
dismissible: true
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@announcement = AppAnnouncement.new(announcement_params)
|
||||
@announcement.created_by_admin = current_admin_account
|
||||
if @announcement.save
|
||||
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
|
||||
else
|
||||
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
if @announcement.update(announcement_params)
|
||||
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
|
||||
else
|
||||
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@announcement.destroy!
|
||||
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_announcement
|
||||
@announcement = AppAnnouncement.find(params[:id])
|
||||
end
|
||||
|
||||
def announcement_params
|
||||
params.require(:app_announcement).permit(
|
||||
:kind, :severity, :status, :title, :body,
|
||||
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
|
||||
:show_on_app, :show_on_web_public, :show_on_web_private
|
||||
).tap do |permitted|
|
||||
%i[dismissible show_on_app show_on_web_public show_on_web_private].each do |key|
|
||||
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
|
||||
end
|
||||
%i[starts_at ends_at action_url action_label].each do |key|
|
||||
permitted[key] = nil if permitted[key].blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
module Api
|
||||
module V1
|
||||
class AnnouncementsController < BaseController
|
||||
skip_before_action :authenticate_request!
|
||||
|
||||
def index
|
||||
platform = params[:platform].to_s.presence
|
||||
announcements = AppAnnouncement.for_app.newest
|
||||
render json: announcements.map { |item| item.as_api_json(platform: platform) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ module Api
|
||||
module V1
|
||||
class BaseController < ApplicationController
|
||||
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
|
||||
|
||||
private
|
||||
@@ -20,6 +21,10 @@ module Api
|
||||
billing_url: error.billing_url
|
||||
}, status: :forbidden
|
||||
end
|
||||
|
||||
def render_cover_upload_error(error)
|
||||
render json: { error: error.message, error_code: "cover_upload_invalid" }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2,6 +2,7 @@ module Api
|
||||
module V1
|
||||
class MatchesController < BaseController
|
||||
include Api::UrlHelper
|
||||
include Api::CoverJson
|
||||
include BrandingAttachments
|
||||
|
||||
before_action :set_team, only: %i[index create]
|
||||
@@ -21,6 +22,7 @@ module Api
|
||||
normalize_scoring_rules!(attrs)
|
||||
match = @team.matches.create!(attrs)
|
||||
attach_opponent_logo(match)
|
||||
attach_match_cover(match)
|
||||
render json: match_json(match), status: :created
|
||||
end
|
||||
|
||||
@@ -34,6 +36,7 @@ module Api
|
||||
normalize_opponent_color!(attrs)
|
||||
@match.update!(attrs)
|
||||
attach_opponent_logo(@match)
|
||||
attach_match_cover(@match)
|
||||
render json: match_json(@match)
|
||||
end
|
||||
|
||||
@@ -98,6 +101,11 @@ module Api
|
||||
match.opponent_logo_file.attach(file) if file.present?
|
||||
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)
|
||||
active = active_session_for(match)
|
||||
team = match.team
|
||||
@@ -123,6 +131,7 @@ module Api
|
||||
home_logo_url: api_absolute_url(team.effective_logo_url),
|
||||
opponent_primary_color: match.effective_opponent_primary_color,
|
||||
opponent_logo_url: api_absolute_url(match.opponent_logo_url),
|
||||
**match_cover_json(match),
|
||||
active_session_id: active&.id,
|
||||
active_session_status: active&.status,
|
||||
stream_completed: match.stream_completed?,
|
||||
|
||||
@@ -137,7 +137,8 @@ module Api
|
||||
youtube_mode: ent.plan.youtube_mode,
|
||||
staff_role: current_user.staff_role_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
|
||||
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?
|
||||
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)
|
||||
v = value.to_s.strip
|
||||
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")
|
||||
|
||||
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,
|
||||
sport: club.sport
|
||||
)
|
||||
|
||||
plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
|
||||
Billing::AssignPlan.call(club: club, plan_slug: plan)
|
||||
desired_plan = params[:plan].presence_in(%w[free premium_light premium_full]) || "free"
|
||||
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?
|
||||
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")
|
||||
return
|
||||
end
|
||||
|
||||
interval = checkout_interval_param
|
||||
redirect_to public_club_checkout_path(club, plan: plan, interval: interval)
|
||||
redirect_to public_club_checkout_path(club, plan: desired_plan, interval: interval)
|
||||
else
|
||||
redirect_to public_club_billing_path(club), notice: t("flash.clubs.created_complete_subscription")
|
||||
end
|
||||
else
|
||||
redirect_to public_club_path(club), notice: t("flash.clubs.created")
|
||||
end
|
||||
@@ -59,17 +64,26 @@ module Public
|
||||
|
||||
def edit
|
||||
require_club_owner!(@club)
|
||||
@entitlements = @club.teams.first&.entitlements
|
||||
end
|
||||
|
||||
def update
|
||||
require_club_owner!(@club)
|
||||
@entitlements = @club.teams.first!.entitlements
|
||||
@club.assign_attributes(club_params)
|
||||
attach_branding_logo(@club)
|
||||
attach_cover_image(@club, entitlements: @entitlements)
|
||||
@club.save!
|
||||
enqueue_cover_slate_generation(@club)
|
||||
redirect_to public_club_path(@club), notice: t("flash.clubs.updated")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@entitlements = @club.teams.first&.entitlements
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
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
|
||||
|
||||
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])
|
||||
)
|
||||
@publisher_online = !@stream_closed && mediamtx_publisher_online?(@session)
|
||||
@cover_poster_url = Streams::CoverResolver.new(@match).effective_cover_url
|
||||
end
|
||||
|
||||
def status
|
||||
|
||||
@@ -9,6 +9,7 @@ module Public
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
before_action :set_site_locale
|
||||
before_action :load_site_announcements
|
||||
|
||||
private
|
||||
|
||||
@@ -40,5 +41,27 @@ module Public
|
||||
def logged_in?
|
||||
current_user.present?
|
||||
end
|
||||
|
||||
PRIVATE_WEB_CONTROLLERS = %w[
|
||||
accounts clubs teams club_recordings club_billing
|
||||
club_matches matches team_roster_members
|
||||
].freeze
|
||||
|
||||
def load_site_announcements
|
||||
@site_announcements = if skip_site_announcements?
|
||||
AppAnnouncement.none
|
||||
else
|
||||
channel = private_web_area? ? :web_private : :web_public
|
||||
AppAnnouncement.for_channel(channel).newest
|
||||
end
|
||||
end
|
||||
|
||||
def skip_site_announcements?
|
||||
controller_name == "regia"
|
||||
end
|
||||
|
||||
def private_web_area?
|
||||
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,7 @@ module Public
|
||||
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
|
||||
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
|
||||
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
|
||||
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
|
||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
|
||||
@@ -19,6 +19,7 @@ module Public
|
||||
require_club_owner!(@club)
|
||||
team = @club.teams.create!(team_params)
|
||||
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)
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
@@ -41,19 +42,28 @@ module Public
|
||||
def edit
|
||||
require_club_owner_for_team!(@team)
|
||||
@club = @team.club
|
||||
@entitlements = @team.entitlements
|
||||
end
|
||||
|
||||
def update
|
||||
require_club_owner_for_team!(@team)
|
||||
@team.assign_attributes(team_params)
|
||||
attach_branding_logo(@team)
|
||||
attach_cover_image(@team, entitlements: @team.entitlements)
|
||||
attach_team_photo(@team)
|
||||
@team.save!
|
||||
enqueue_cover_slate_generation(@team)
|
||||
redirect_to public_team_details_path(@team), notice: t("flash.teams.updated")
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@club = @team.club
|
||||
@entitlements = @team.entitlements
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
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
|
||||
|
||||
def invite
|
||||
|
||||
@@ -37,4 +37,31 @@ module AdminHelper
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def announcement_status_badge(item)
|
||||
case item.status
|
||||
when "published" then item.severity == "critical" ? "live" : "ready"
|
||||
when "archived" then "paused"
|
||||
else "connecting"
|
||||
end
|
||||
end
|
||||
|
||||
def announcement_window_label(item)
|
||||
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
|
||||
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
|
||||
if start_label && end_label
|
||||
"#{start_label} → #{end_label}"
|
||||
elsif start_label
|
||||
I18n.t("admin.announcements.window.from", time: start_label)
|
||||
elsif end_label
|
||||
I18n.t("admin.announcements.window.until", time: end_label)
|
||||
else
|
||||
I18n.t("admin.announcements.window.always")
|
||||
end
|
||||
end
|
||||
|
||||
def announcement_channels_label(item)
|
||||
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
||||
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module LegalHelper
|
||||
def legal_last_updated
|
||||
"3 giugno 2026"
|
||||
"20 agosto 2026"
|
||||
end
|
||||
|
||||
def cookie_policy_last_updated
|
||||
|
||||
@@ -11,6 +11,13 @@ module Public
|
||||
quote = club&.active_billing_quote
|
||||
if quote
|
||||
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] }
|
||||
end
|
||||
|
||||
@@ -19,6 +26,9 @@ module Public
|
||||
|
||||
intervals = bank_transfer_intervals_for(target_plan)
|
||||
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?
|
||||
return { kind: :bank_only, plan: target_plan, intervals: intervals }
|
||||
end
|
||||
@@ -28,6 +38,9 @@ module Public
|
||||
|
||||
stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
|
||||
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?
|
||||
return { kind: :bank_only, plan: target_plan, intervals: intervals }
|
||||
end
|
||||
@@ -54,6 +67,10 @@ module Public
|
||||
}
|
||||
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
|
||||
{ kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription }
|
||||
else
|
||||
@@ -136,8 +153,21 @@ module Public
|
||||
return false unless MatchLiveTv.bank_transfer_configured?
|
||||
return false if quote && !quote.matches?(plan.slug, interval)
|
||||
return false if pending_transfer&.awaiting_payment?
|
||||
return false if current_paid_plan?(club.subscription, plan)
|
||||
|
||||
true
|
||||
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
|
||||
|
||||
@@ -52,6 +52,18 @@ module Public
|
||||
muteError: t("regia.js.mute_error"),
|
||||
closeError: t("regia.js.close_error"),
|
||||
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"),
|
||||
linkCopied: t("regia.js.link_copied"),
|
||||
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
|
||||
@@ -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
|
||||
@@ -2,6 +2,7 @@ class AdminAccount < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
has_secure_password
|
||||
has_many :app_announcements, foreign_key: :created_by_admin_id, dependent: :nullify, inverse_of: :created_by_admin
|
||||
|
||||
validates :username, presence: true, uniqueness: true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
class AppAnnouncement < ApplicationRecord
|
||||
KINDS = %w[info maintenance update].freeze
|
||||
SEVERITIES = %w[info warning critical].freeze
|
||||
STATUSES = %w[draft published archived].freeze
|
||||
CHANNELS = {
|
||||
app: :show_on_app,
|
||||
web_public: :show_on_web_public,
|
||||
web_private: :show_on_web_private
|
||||
}.freeze
|
||||
|
||||
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
|
||||
|
||||
validates :kind, :severity, :status, :title, :body, presence: true
|
||||
validates :kind, inclusion: { in: KINDS }
|
||||
validates :severity, inclusion: { in: SEVERITIES }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :title, length: { maximum: 120 }
|
||||
validates :body, length: { maximum: 2000 }
|
||||
validates :action_label, length: { maximum: 40 }, allow_blank: true
|
||||
validates :action_url, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
|
||||
validate :ends_at_after_starts_at
|
||||
validate :at_least_one_channel
|
||||
|
||||
scope :newest, -> { order(created_at: :desc) }
|
||||
scope :published, -> { where(status: "published") }
|
||||
scope :active_now, lambda {
|
||||
now = Time.current
|
||||
published
|
||||
.where("starts_at IS NULL OR starts_at <= ?", now)
|
||||
.where("ends_at IS NULL OR ends_at > ?", now)
|
||||
}
|
||||
|
||||
def self.for_channel(channel)
|
||||
column = CHANNELS[channel.to_sym]
|
||||
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
|
||||
|
||||
active_now.where(column => true)
|
||||
end
|
||||
|
||||
def self.for_app
|
||||
for_channel(:app)
|
||||
end
|
||||
|
||||
def published?
|
||||
status == "published"
|
||||
end
|
||||
|
||||
def selected_channels
|
||||
CHANNELS.each_with_object([]) do |(key, column), keys|
|
||||
keys << key if public_send(column)
|
||||
end
|
||||
end
|
||||
|
||||
def as_api_json(platform: nil)
|
||||
{
|
||||
id: id,
|
||||
kind: kind,
|
||||
severity: severity,
|
||||
title: title,
|
||||
body: body,
|
||||
dismissible: dismissible,
|
||||
action_url: resolved_action_url(platform),
|
||||
action_label: action_label.presence,
|
||||
starts_at: starts_at&.iso8601,
|
||||
ends_at: ends_at&.iso8601
|
||||
}
|
||||
end
|
||||
|
||||
def resolved_action_url(platform)
|
||||
return action_url if action_url.present?
|
||||
return nil unless kind == "update"
|
||||
|
||||
case platform.to_s
|
||||
when "android" then MatchLiveTv.play_store_url
|
||||
when "ios" then MatchLiveTv.app_store_url
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def at_least_one_channel
|
||||
return if show_on_app? || show_on_web_public? || show_on_web_private?
|
||||
|
||||
errors.add(:base, :no_channel)
|
||||
end
|
||||
|
||||
def ends_at_after_starts_at
|
||||
return if starts_at.blank? || ends_at.blank?
|
||||
return if ends_at > starts_at
|
||||
|
||||
errors.add(:ends_at, :invalid)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,6 @@
|
||||
class Club < ApplicationRecord
|
||||
include Brandable
|
||||
include Coverable
|
||||
include ClubBillingProfile
|
||||
|
||||
has_many :club_memberships, dependent: :destroy
|
||||
|
||||
@@ -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
|
||||
@@ -1,4 +1,6 @@
|
||||
class Match < ApplicationRecord
|
||||
include Coverable
|
||||
|
||||
belongs_to :team
|
||||
has_many :stream_sessions, dependent: :destroy
|
||||
|
||||
@@ -174,4 +176,8 @@ class Match < ApplicationRecord
|
||||
rescue ArgumentError => e
|
||||
errors.add(:scoring_rules, e.message)
|
||||
end
|
||||
|
||||
def cover_parent
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -84,6 +84,10 @@ class Plan < ApplicationRecord
|
||||
feature("youtube_enabled") == true
|
||||
end
|
||||
|
||||
def custom_cover_enabled?
|
||||
feature("custom_cover") == true
|
||||
end
|
||||
|
||||
def premium?
|
||||
slug.in?(PREMIUM_SLUGS)
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Team < ApplicationRecord
|
||||
include Brandable
|
||||
include Coverable
|
||||
|
||||
belongs_to :club
|
||||
has_many :user_teams, dependent: :destroy
|
||||
@@ -99,6 +100,10 @@ class Team < ApplicationRecord
|
||||
club
|
||||
end
|
||||
|
||||
def cover_parent
|
||||
club
|
||||
end
|
||||
|
||||
def assign_slug
|
||||
self.slug = Teams::GenerateSlug.call(self) if slug.blank?
|
||||
end
|
||||
|
||||
@@ -88,7 +88,7 @@ module Billing
|
||||
mailer_action: :plan_activated_with_invoice
|
||||
)
|
||||
else
|
||||
BankTransferMailer.with(order: @order.reload).plan_activated.deliver_now
|
||||
MatchLiveTv.deliver_mail(BankTransferMailer.with(order: @order.reload).plan_activated)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@ module Billing
|
||||
|
||||
@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)
|
||||
end
|
||||
|
||||
@invoice
|
||||
end
|
||||
|
||||
@@ -28,7 +28,6 @@ module Billing
|
||||
existing = @club.billing_transfer_orders.awaiting_payment.first
|
||||
if existing
|
||||
if existing.plan_slug == @plan_slug && existing.billing_interval == @interval && existing.amount_cents == amount_cents
|
||||
BankTransferMailer.with(order: existing).instructions.deliver_now
|
||||
return existing
|
||||
end
|
||||
|
||||
@@ -59,7 +58,8 @@ module Billing
|
||||
)
|
||||
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
|
||||
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
|
||||
@@ -28,7 +28,7 @@ module Mediamtx
|
||||
overridePublisher: true
|
||||
)
|
||||
body[:alwaysAvailable] = true
|
||||
body[:alwaysAvailableFile] = slate_file_path
|
||||
body[:alwaysAvailableFile] = slate_file_path(session)
|
||||
# YouTube: telefono → MediaMTX; relay copy verso RTMPS in sidekiq.
|
||||
response = @conn.post("/v3/config/paths/add/#{CGI.escape(path)}", body)
|
||||
unless response.success?
|
||||
@@ -74,6 +74,47 @@ module Mediamtx
|
||||
set_always_available(session, enabled: true)
|
||||
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.
|
||||
# Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
|
||||
def set_always_available(session, enabled:)
|
||||
@@ -81,7 +122,7 @@ module Mediamtx
|
||||
return true if always_available_remembered?(path, enabled: enabled)
|
||||
|
||||
body = if enabled
|
||||
{ alwaysAvailable: true, alwaysAvailableFile: slate_file_path }
|
||||
{ alwaysAvailable: true, alwaysAvailableFile: slate_file_path(session) }
|
||||
else
|
||||
{ alwaysAvailable: false }
|
||||
end
|
||||
@@ -173,7 +214,13 @@ module Mediamtx
|
||||
SCRIPT
|
||||
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")
|
||||
end
|
||||
|
||||
|
||||
@@ -79,9 +79,8 @@ module Mediamtx
|
||||
end
|
||||
|
||||
def restore_slate_path!(session)
|
||||
return if session.platform == "matchlivetv"
|
||||
|
||||
Client.for_session(session).set_always_available(session, enabled: true)
|
||||
# Anche su matchlivetv: dopo restart MediaMTX la path può cadere su all_others senza slate.
|
||||
Client.for_session(session).ensure_path_with_slate!(session)
|
||||
rescue Client::Error => e
|
||||
Rails.logger.warn("[PublisherSync] enable slate session=#{session.id}: #{e.message}")
|
||||
end
|
||||
@@ -146,6 +145,14 @@ module Mediamtx
|
||||
nil
|
||||
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)
|
||||
"mediamtx:recording:#{session_id}"
|
||||
end
|
||||
|
||||
@@ -37,6 +37,8 @@ module Sessions
|
||||
session.stream_node = Streams::NodeRegistry.allocate!
|
||||
session.save!
|
||||
Scoring::Engine.ensure_score_for(session)
|
||||
Streams::CoverSlateEnsurer.ensure_for!(@match)
|
||||
Streams::SlateDistributor.ensure_for!(session)
|
||||
Mediamtx::Client.for_session(session).create_path(session)
|
||||
log_event(
|
||||
session,
|
||||
|
||||
@@ -7,11 +7,13 @@ module Sessions
|
||||
def call
|
||||
cancel_timeout_job
|
||||
@session.pause! if @session.may_pause?
|
||||
Mediamtx::PublisherSync.forget_recording_state!(@session.id)
|
||||
begin
|
||||
mtx = Mediamtx::Client.for_session(@session)
|
||||
mtx.set_path_recording(@session, enabled: false)
|
||||
mtx.set_always_available(@session, enabled: true)
|
||||
# Ricrea/riallinea slate prima di spegnere l'RTMP del telefono.
|
||||
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
|
||||
Rails.logger.warn("[Sessions::Pause] MediaMTX: #{e.message}")
|
||||
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
|
||||
@@ -35,6 +35,20 @@ module Teams
|
||||
active? && plan.phone_download_enabled?
|
||||
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
|
||||
plan.max_staff_transmission
|
||||
end
|
||||
@@ -225,7 +239,8 @@ module Teams
|
||||
recording_retention_days: recording_retention_days,
|
||||
phone_download_enabled: phone_download_enabled?,
|
||||
youtube_enabled: youtube_enabled?,
|
||||
youtube_mode: plan.youtube_mode
|
||||
youtube_mode: plan.youtube_mode,
|
||||
custom_cover_enabled: can_use_custom_cover?
|
||||
}
|
||||
end
|
||||
|
||||
|
||||
@@ -23,5 +23,12 @@ module Teams
|
||||
ut.update!(staff_kind: "transmission")
|
||||
ut
|
||||
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
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<%= form_with model: announcement, url: (announcement.persisted? ? admin_announcement_path(announcement) : admin_announcements_path),
|
||||
method: (announcement.persisted? ? :patch : :post), local: true, class: "admin-form" do |f| %>
|
||||
<div class="admin-form-row">
|
||||
<div>
|
||||
<%= f.label :kind, t("admin.announcements.form.kind") %>
|
||||
<%= f.select :kind, AppAnnouncement::KINDS.map { |k| [t("admin.announcements.kind.#{k}"), k] } %>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :severity, t("admin.announcements.form.severity") %>
|
||||
<%= f.select :severity, AppAnnouncement::SEVERITIES.map { |k| [t("admin.announcements.severity.#{k}"), k] } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :status, t("admin.announcements.form.status") %>
|
||||
<%= f.select :status, AppAnnouncement::STATUSES.map { |k| [t("admin.announcements.status.#{k}"), k] } %>
|
||||
</div>
|
||||
|
||||
<fieldset class="admin-channels">
|
||||
<legend><%= t("admin.announcements.form.channels") %></legend>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_app %>
|
||||
<span><%= t("admin.announcements.channels.app") %></span>
|
||||
</label>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_web_public %>
|
||||
<span><%= t("admin.announcements.channels.web_public") %></span>
|
||||
</label>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_web_private %>
|
||||
<span><%= t("admin.announcements.channels.web_private") %></span>
|
||||
</label>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
||||
</fieldset>
|
||||
|
||||
<div>
|
||||
<%= f.label :title, t("admin.announcements.form.title") %>
|
||||
<%= f.text_field :title, required: true, maxlength: 120 %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :body, t("admin.announcements.form.body") %>
|
||||
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000 %>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-row">
|
||||
<div>
|
||||
<%= f.label :starts_at, t("admin.announcements.form.starts_at") %>
|
||||
<%= f.datetime_field :starts_at, value: announcement.starts_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :ends_at, t("admin.announcements.form.ends_at") %>
|
||||
<%= f.datetime_field :ends_at, value: announcement.ends_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
|
||||
|
||||
<div>
|
||||
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
|
||||
<%= f.url_field :action_url, placeholder: "https://" %>
|
||||
</div>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
|
||||
|
||||
<div>
|
||||
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
|
||||
<%= f.text_field :action_label, maxlength: 40 %>
|
||||
</div>
|
||||
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :dismissible %>
|
||||
<span><%= t("admin.announcements.form.dismissible") %></span>
|
||||
</label>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.dismissible_hint") %></p>
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<%= f.submit (announcement.persisted? ? t("admin.announcements.form.submit_update") : t("admin.announcements.form.submit_create")), class: "admin-btn admin-btn--primary" %>
|
||||
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.announcements.edit.title") %></h2>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<%= render "form", announcement: @announcement %>
|
||||
</div>
|
||||
@@ -0,0 +1,56 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.announcements.index.title") %></h2>
|
||||
<p class="muted admin-page-sub"><%= t("admin.announcements.index.lead") %></p>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:1.5rem">
|
||||
<div class="admin-toolbar">
|
||||
<%= link_to t("admin.announcements.index.new"), new_admin_announcement_path, class: "admin-btn admin-btn--primary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2><%= t("admin.announcements.index.table_title") %></h2>
|
||||
<% if @announcements.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.announcements.table.status") %></th>
|
||||
<th><%= t("admin.announcements.table.kind") %></th>
|
||||
<th><%= t("admin.announcements.table.title") %></th>
|
||||
<th><%= t("admin.announcements.table.channels") %></th>
|
||||
<th><%= t("admin.announcements.table.window") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @announcements.each do |item| %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge badge--<%= announcement_status_badge(item) %>"><%= t("admin.announcements.status.#{item.status}") %></span>
|
||||
</td>
|
||||
<td class="muted"><%= t("admin.announcements.kind.#{item.kind}") %></td>
|
||||
<td>
|
||||
<strong><%= item.title %></strong>
|
||||
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(item.body, length: 90) %></div>
|
||||
</td>
|
||||
<td class="muted"><%= announcement_channels_label(item) %></td>
|
||||
<td class="muted"><%= announcement_window_label(item) %></td>
|
||||
<td class="admin-actions">
|
||||
<%= link_to t("admin.announcements.actions.edit"), edit_admin_announcement_path(item), class: "admin-btn admin-btn--sm" %>
|
||||
<%= button_to t("admin.announcements.actions.delete"), admin_announcement_path(item), method: :delete,
|
||||
class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||
form: { data: { confirm: t("admin.announcements.actions.delete_confirm") } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.announcements.index.none") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.announcements.new.title") %></h2>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<%= render "form", announcement: @announcement %>
|
||||
</div>
|
||||
@@ -42,6 +42,10 @@
|
||||
</header>
|
||||
<div class="billing-pending-card__body">
|
||||
<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") %>:
|
||||
<strong><%= order.payment_causal %></strong>
|
||||
</p>
|
||||
|
||||
@@ -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") %>
|
||||
@@ -4,7 +4,7 @@
|
||||
<title><%= t("admin.layout.title") %></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="/admin.css?v=4">
|
||||
<link rel="stylesheet" href="/admin.css?v=6">
|
||||
<% if content_for?(:replay_archive_styles) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
@@ -13,7 +13,7 @@
|
||||
<script src="/admin-dashboard.js?v=1" defer></script>
|
||||
<% end %>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=6" defer></script>
|
||||
<script src="/confirm-forms.js?v=7" defer></script>
|
||||
</head>
|
||||
<body class="<%= content_for?(:body_class) ? yield(:body_class) : 'admin-body' %>">
|
||||
<header class="admin-header">
|
||||
@@ -25,6 +25,7 @@
|
||||
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
|
||||
<%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
|
||||
<% end %>
|
||||
<%= link_to t("admin.layout.nav.announcements"), admin_announcements_path, class: ("active" if controller_name == "announcements") %>
|
||||
<%= link_to t("admin.layout.nav.clubs"), admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
|
||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=64">
|
||||
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||
<%= render "shared/site_announcements" %>
|
||||
<main>
|
||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||
@@ -23,7 +24,7 @@
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
<script src="/confirm-forms.js?v=6" defer></script>
|
||||
<script src="/confirm-forms.js?v=7" defer></script>
|
||||
<script src="/cookie-consent.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=64">
|
||||
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
<%= render "shared/site_announcements" %>
|
||||
<main class="live-main">
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<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>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<% if @quote %>
|
||||
<%= render "shared/quoted_price_banner", quote: @quote %>
|
||||
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
|
||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||
<%= render "shared/stripe_secure_payment" %>
|
||||
<% end %>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% content_for :title, t("club.edit.title", name: @club.name) %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<div class="wrap wrap--portal" style="padding-top:20px">
|
||||
<h1><%= t("club.edit.heading") %></h1>
|
||||
<div class="card">
|
||||
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
|
||||
@@ -10,6 +10,9 @@
|
||||
<%= label_tag "club[sport]", t("club.sport_label") %>
|
||||
<%= select_tag "club[sport]", sport_catalog_options(@club.sport) %>
|
||||
<%= 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 %>
|
||||
<%= submit_tag t("club.edit.save"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
|
||||
@@ -24,15 +24,8 @@
|
||||
[t("club.new.plan_free"), "free"],
|
||||
[t("club.new.plan_light"), "premium_light"],
|
||||
[t("club.new.plan_full"), "premium_full"]
|
||||
], params[:plan] || "free"), id: "club_plan_select" %>
|
||||
<div id="club_plan_interval" style="margin-top:10px">
|
||||
<%= 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 %>
|
||||
], params[:plan] || "free") %>
|
||||
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
|
||||
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</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>
|
||||
@@ -46,7 +46,7 @@
|
||||
</div>
|
||||
<% else %>
|
||||
<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 %>
|
||||
<button type="button" id="play-hint" class="live-play-hint" hidden>
|
||||
<%= t("live.show.play_hint") %>
|
||||
@@ -219,7 +219,8 @@
|
||||
}
|
||||
|
||||
function onEnterPause() {
|
||||
nudgePlayback();
|
||||
// Camera → slate cambia risoluzione/codec: senza reload HLS.js resta spesso bloccato.
|
||||
hardReloadPlayer();
|
||||
}
|
||||
|
||||
function hardReloadPlayer() {
|
||||
|
||||
@@ -83,6 +83,6 @@
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||
<%= link_to t("pages.pricing.contact_button"), public_contatti_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<li><%= raw t("legal.privacy.s3_item4_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s3_item5_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s3_item6_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s3_item7_html") %></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -107,6 +108,10 @@
|
||||
<td><%= t("legal.privacy.s5_row6_purpose") %></td>
|
||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><%= t("legal.privacy.s5_row7_purpose") %></td>
|
||||
<td><%= t("legal.privacy.s5_row7_basis") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -144,6 +149,7 @@
|
||||
<li><%= raw t("legal.privacy.s8_item2_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s8_item3_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s8_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
|
||||
<li><%= raw t("legal.privacy.s8_item5_html") %></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -165,4 +165,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=19"></script>
|
||||
<script src="/regia.js?v=20"></script>
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<% team = local_assigns[:team] %>
|
||||
<% club = local_assigns[:club] %>
|
||||
<% can_manage = local_assigns[:can_manage] %>
|
||||
<% owner_membership = local_assigns[:owner_membership] %>
|
||||
<% staff_memberships = local_assigns[:staff_memberships] %>
|
||||
<% pending_invitations = local_assigns[:pending_invitations] %>
|
||||
<% recordings = local_assigns[:recordings] %>
|
||||
<% 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">
|
||||
<% if can_manage %>
|
||||
<% if owner_membership&.staff_kind.blank? %>
|
||||
<% if club_owner %>
|
||||
<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">
|
||||
<%= raw t("team.streaming_staff.self_account_hint") %>
|
||||
<%= raw t("team.streaming_staff.principal_score_hint_html") %>
|
||||
</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>
|
||||
<% 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">
|
||||
<% if staff_memberships.any? %>
|
||||
<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>
|
||||
<tbody>
|
||||
<% staff_memberships.each do |ut| %>
|
||||
<% extra_staff.each do |ut| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= ut.user.name %>
|
||||
@@ -37,9 +37,7 @@
|
||||
<td><%= t("team.streaming_staff.role_transmission") %></td>
|
||||
<% if can_manage %>
|
||||
<td>
|
||||
<% if ut.user_id == current_user.id && team.club.owned_by?(current_user) %>
|
||||
<%= 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" %>
|
||||
<% if 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" } } %>
|
||||
<% end %>
|
||||
</td>
|
||||
@@ -48,13 +46,8 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</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>
|
||||
<% end %>
|
||||
|
||||
<% if can_manage %>
|
||||
<div class="card team-invite-form" id="invita-trasmissione">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% content_for :title, t("team.edit.title", name: @team.name) %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<div class="wrap wrap--portal" style="padding-top:20px">
|
||||
<h1><%= @team.name %></h1>
|
||||
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
|
||||
<div class="card">
|
||||
@@ -25,6 +25,10 @@
|
||||
</div>
|
||||
|
||||
<%= 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") %>
|
||||
<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" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to t("common.contacts"), public_contatti_path %> ·
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
<%# locals: (order:) %>
|
||||
<% return if order.blank? %>
|
||||
<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>
|
||||
<%= raw t("billing.bank_transfer.pending_body_html",
|
||||
plan: order.plan.name,
|
||||
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)) %>
|
||||
price: order.price_label) %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -109,7 +109,7 @@
|
||||
public_club_billing_bank_transfer_path(club, plan: plan.slug, interval: interval),
|
||||
method: :post,
|
||||
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 %>
|
||||
</div>
|
||||
<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? %>
|
||||
<% quoted_plan_active = current_paid_plan?(subscription, quote.plan) %>
|
||||
<div class="card" style="margin-top:16px;border-color:#2e5a3c;background:#142018">
|
||||
<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>
|
||||
<%= 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 %>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<% announcements = @site_announcements %>
|
||||
<% if announcements.present? %>
|
||||
<div class="site-announcements" role="region" aria-label="<%= t("announcement.region") %>">
|
||||
<% announcements.each do |item| %>
|
||||
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
||||
data-announcement-id="<%= item.id %>">
|
||||
<div class="site-announcement__body">
|
||||
<p class="site-announcement__title"><%= item.title %></p>
|
||||
<div class="site-announcement__text"><%= simple_format(item.body) %></div>
|
||||
<% if item.action_url.present? %>
|
||||
<p class="site-announcement__action">
|
||||
<%= link_to (item.action_label.presence || t("announcement.open")), item.action_url,
|
||||
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if item.dismissible? %>
|
||||
<button type="button" class="site-announcement__dismiss" data-announcement-dismiss aria-label="<%= t("announcement.dismiss") %>">
|
||||
×
|
||||
</button>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var key = "mltv.webDismissedAnnouncements";
|
||||
var dismissed = [];
|
||||
try { dismissed = JSON.parse(localStorage.getItem(key) || "[]"); } catch (e) {}
|
||||
document.querySelectorAll(".site-announcement[data-announcement-id]").forEach(function (el) {
|
||||
if (dismissed.indexOf(el.getAttribute("data-announcement-id")) !== -1) el.remove();
|
||||
});
|
||||
document.querySelectorAll("[data-announcement-dismiss]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var box = btn.closest("[data-announcement-id]");
|
||||
if (!box) return;
|
||||
var id = box.getAttribute("data-announcement-id");
|
||||
if (dismissed.indexOf(id) === -1) dismissed.push(id);
|
||||
localStorage.setItem(key, JSON.stringify(dismissed));
|
||||
box.remove();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -21,7 +21,7 @@ module MatchLiveTv
|
||||
end
|
||||
|
||||
def hls_public_url
|
||||
ENV.fetch("HLS_PUBLIC_URL", "http://localhost:8888")
|
||||
ENV.fetch("HLS_PUBLIC_URL") { "#{app_public_url.chomp('/')}/hls" }
|
||||
end
|
||||
|
||||
def mediamtx_hls_url
|
||||
@@ -97,6 +97,21 @@ module MatchLiveTv
|
||||
bank_transfer_iban.present? && bank_transfer_account_holder.present?
|
||||
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
|
||||
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ de:
|
||||
nav:
|
||||
dashboard: Dashboard
|
||||
ops: Ops
|
||||
announcements: Hinweise
|
||||
clubs: Vereine & Teams
|
||||
billing: Abrechnung
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ de:
|
||||
invoice_updated: "Rechnung %{number} aktualisiert."
|
||||
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
|
||||
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
|
||||
announcement_created: Hinweis gespeichert
|
||||
announcement_updated: Hinweis aktualisiert
|
||||
announcement_destroyed: Hinweis gelöscht
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Ja"
|
||||
@@ -57,7 +61,7 @@ de:
|
||||
username_label: Benutzername
|
||||
password_label: Passwort
|
||||
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:
|
||||
edit:
|
||||
title: Passwort ändern
|
||||
@@ -360,3 +364,64 @@ de:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replays — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Hinweise
|
||||
lead: Meldungen für die Apps, die öffentliche Website und den Login-Bereich — Wartung, Updates oder Mitteilungen.
|
||||
new: Neuer Hinweis
|
||||
table_title: Hinweise
|
||||
none: Noch keine Hinweise. Erstelle einen, um Apps und Website zu informieren.
|
||||
new:
|
||||
title: Neuer Hinweis
|
||||
edit:
|
||||
title: Hinweis bearbeiten
|
||||
table:
|
||||
status: Status
|
||||
kind: Typ
|
||||
title: Nachricht
|
||||
channels: Ziele
|
||||
window: Zeitraum
|
||||
actions:
|
||||
edit: Bearbeiten
|
||||
delete: Löschen
|
||||
delete_confirm: Diesen Hinweis löschen? Er erscheint nicht mehr an den gewählten Stellen.
|
||||
kind:
|
||||
info: Information
|
||||
maintenance: Wartung
|
||||
update: App-Update
|
||||
severity:
|
||||
info: Info
|
||||
warning: Warnung
|
||||
critical: Kritisch
|
||||
status:
|
||||
draft: Entwurf
|
||||
published: Veröffentlicht
|
||||
archived: Archiviert
|
||||
channels:
|
||||
app: Apps (Android und iOS)
|
||||
web_public: Öffentliche Website
|
||||
web_private: Login-Bereich
|
||||
window:
|
||||
always: Immer (solange veröffentlicht)
|
||||
from: "Ab %{time}"
|
||||
until: "Bis %{time}"
|
||||
form:
|
||||
kind: Typ
|
||||
severity: Schwere
|
||||
status: Status
|
||||
channels: Wo anzeigen
|
||||
channels_hint: Eine oder mehrere Ziele wählen. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto).
|
||||
title: Titel
|
||||
body: Text
|
||||
starts_at: Sichtbar ab
|
||||
ends_at: Sichtbar bis
|
||||
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
|
||||
action_url: Link (optional)
|
||||
action_url_hint: Bei Updates öffnet eine leere URL den Store. Sonst eine https-URL.
|
||||
action_label: Button-Text
|
||||
dismissible: Nutzer können den Hinweis schließen
|
||||
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
|
||||
submit_create: Hinweis speichern
|
||||
submit_update: Hinweis aktualisieren
|
||||
cancel: Abbrechen
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ en:
|
||||
nav:
|
||||
dashboard: Dashboard
|
||||
ops: Ops
|
||||
announcements: Notices
|
||||
clubs: Clubs & teams
|
||||
billing: Billing
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ en:
|
||||
invoice_updated: "Invoice %{number} updated."
|
||||
invoice_uploaded: "Invoice uploaded and sent to %{email}."
|
||||
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
|
||||
announcement_created: Notice saved
|
||||
announcement_updated: Notice updated
|
||||
announcement_destroyed: Notice deleted
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Yes"
|
||||
@@ -57,7 +61,7 @@ en:
|
||||
username_label: Username
|
||||
password_label: Password
|
||||
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:
|
||||
edit:
|
||||
title: Change password
|
||||
@@ -360,3 +364,64 @@ en:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replays — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Notices
|
||||
lead: Messages for the apps, the public website and the logged-in area — maintenance, updates or announcements.
|
||||
new: New notice
|
||||
table_title: Notices
|
||||
none: No notices yet. Create one to notify the apps and the website.
|
||||
new:
|
||||
title: New notice
|
||||
edit:
|
||||
title: Edit notice
|
||||
table:
|
||||
status: Status
|
||||
kind: Type
|
||||
title: Message
|
||||
channels: Destinations
|
||||
window: Window
|
||||
actions:
|
||||
edit: Edit
|
||||
delete: Delete
|
||||
delete_confirm: Delete this notice? It will no longer appear in the selected destinations.
|
||||
kind:
|
||||
info: Information
|
||||
maintenance: Maintenance
|
||||
update: App update
|
||||
severity:
|
||||
info: Info
|
||||
warning: Warning
|
||||
critical: Critical
|
||||
status:
|
||||
draft: Draft
|
||||
published: Published
|
||||
archived: Archived
|
||||
channels:
|
||||
app: Apps (Android and iOS)
|
||||
web_public: Public website
|
||||
web_private: Logged-in area
|
||||
window:
|
||||
always: Always (while published)
|
||||
from: "From %{time}"
|
||||
until: "Until %{time}"
|
||||
form:
|
||||
kind: Type
|
||||
severity: Severity
|
||||
status: Status
|
||||
channels: Where to show it
|
||||
channels_hint: Select one or more destinations. The logged-in area is visible after sign-in (club, teams, account).
|
||||
title: Title
|
||||
body: Body
|
||||
starts_at: Visible from
|
||||
ends_at: Visible until
|
||||
window_hint: Leave empty to show immediately while published.
|
||||
action_url: Link (optional)
|
||||
action_url_hint: For updates, an empty URL opens the store. Otherwise use an https URL.
|
||||
action_label: Button label
|
||||
dismissible: Users can dismiss the notice
|
||||
dismissible_hint: Uncheck for maintenance notices that should stay visible.
|
||||
submit_create: Save notice
|
||||
submit_update: Update notice
|
||||
cancel: Cancel
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ es:
|
||||
nav:
|
||||
dashboard: Panel
|
||||
ops: Ops
|
||||
announcements: Avisos
|
||||
clubs: Clubes y equipos
|
||||
billing: Facturación
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ es:
|
||||
invoice_updated: "Factura %{number} actualizada."
|
||||
invoice_uploaded: "Factura subida y enviada a %{email}."
|
||||
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
|
||||
announcement_created: Aviso guardado
|
||||
announcement_updated: Aviso actualizado
|
||||
announcement_destroyed: Aviso eliminado
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Sí"
|
||||
@@ -57,7 +61,7 @@ es:
|
||||
username_label: Usuario
|
||||
password_label: Contraseña
|
||||
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:
|
||||
edit:
|
||||
title: Cambiar contraseña
|
||||
@@ -360,3 +364,64 @@ es:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Repeticiones — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Avisos
|
||||
lead: Mensajes hacia las apps, el sitio público y el área privada — mantenimiento, actualizaciones o comunicados.
|
||||
new: Nuevo aviso
|
||||
table_title: Avisos
|
||||
none: Todavía no hay avisos. Crea uno para notificar a las apps y al sitio.
|
||||
new:
|
||||
title: Nuevo aviso
|
||||
edit:
|
||||
title: Editar aviso
|
||||
table:
|
||||
status: Estado
|
||||
kind: Tipo
|
||||
title: Mensaje
|
||||
channels: Destinos
|
||||
window: Ventana
|
||||
actions:
|
||||
edit: Editar
|
||||
delete: Eliminar
|
||||
delete_confirm: ¿Eliminar este aviso? Ya no se verá en los destinos elegidos.
|
||||
kind:
|
||||
info: Información
|
||||
maintenance: Mantenimiento
|
||||
update: Actualización
|
||||
severity:
|
||||
info: Info
|
||||
warning: Atención
|
||||
critical: Crítico
|
||||
status:
|
||||
draft: Borrador
|
||||
published: Publicado
|
||||
archived: Archivado
|
||||
channels:
|
||||
app: Apps (Android e iOS)
|
||||
web_public: Sitio público
|
||||
web_private: Área privada
|
||||
window:
|
||||
always: Siempre (mientras esté publicado)
|
||||
from: "Desde %{time}"
|
||||
until: "Hasta %{time}"
|
||||
form:
|
||||
kind: Tipo
|
||||
severity: Gravedad
|
||||
status: Estado
|
||||
channels: Dónde mostrarlo
|
||||
channels_hint: Elige uno o más destinos. El área privada se ve tras iniciar sesión (club, equipos, cuenta).
|
||||
title: Título
|
||||
body: Texto
|
||||
starts_at: Visible desde
|
||||
ends_at: Visible hasta
|
||||
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
|
||||
action_url: Enlace (opcional)
|
||||
action_url_hint: En actualizaciones, una URL vacía abre la tienda. Si no, usa una URL https.
|
||||
action_label: Texto del botón
|
||||
dismissible: El usuario puede cerrar el aviso
|
||||
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
|
||||
submit_create: Guardar aviso
|
||||
submit_update: Actualizar aviso
|
||||
cancel: Cancelar
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ fr:
|
||||
nav:
|
||||
dashboard: Tableau de bord
|
||||
ops: Ops
|
||||
announcements: Alertes
|
||||
clubs: Clubs et équipes
|
||||
billing: Facturation
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ fr:
|
||||
invoice_updated: "Facture %{number} mise à jour."
|
||||
invoice_uploaded: "Facture chargée et envoyée à %{email}."
|
||||
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
|
||||
announcement_created: Alerte enregistrée
|
||||
announcement_updated: Alerte mise à jour
|
||||
announcement_destroyed: Alerte supprimée
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Oui"
|
||||
@@ -57,7 +61,7 @@ fr:
|
||||
username_label: Nom d'utilisateur
|
||||
password_label: Mot de passe
|
||||
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:
|
||||
edit:
|
||||
title: Changer le mot de passe
|
||||
@@ -360,3 +364,64 @@ fr:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replays — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Alertes
|
||||
lead: Messages vers les apps, le site public et l'espace connecté — maintenance, mises à jour ou communications.
|
||||
new: Nouvelle alerte
|
||||
table_title: Alertes
|
||||
none: Aucune alerte. Créez-en une pour notifier les applications et le site.
|
||||
new:
|
||||
title: Nouvelle alerte
|
||||
edit:
|
||||
title: Modifier l'alerte
|
||||
table:
|
||||
status: Statut
|
||||
kind: Type
|
||||
title: Message
|
||||
channels: Destinations
|
||||
window: Fenêtre
|
||||
actions:
|
||||
edit: Modifier
|
||||
delete: Supprimer
|
||||
delete_confirm: Supprimer cette alerte ? Elle n'apparaîtra plus aux destinations choisies.
|
||||
kind:
|
||||
info: Information
|
||||
maintenance: Maintenance
|
||||
update: Mise à jour
|
||||
severity:
|
||||
info: Info
|
||||
warning: Attention
|
||||
critical: Critique
|
||||
status:
|
||||
draft: Brouillon
|
||||
published: Publié
|
||||
archived: Archivé
|
||||
channels:
|
||||
app: Apps (Android et iOS)
|
||||
web_public: Site public
|
||||
web_private: Espace connecté
|
||||
window:
|
||||
always: Toujours (tant que publié)
|
||||
from: "À partir de %{time}"
|
||||
until: "Jusqu'à %{time}"
|
||||
form:
|
||||
kind: Type
|
||||
severity: Gravité
|
||||
status: Statut
|
||||
channels: Où l'afficher
|
||||
channels_hint: Choisissez une ou plusieurs destinations. L'espace connecté est visible après connexion (club, équipes, compte).
|
||||
title: Titre
|
||||
body: Texte
|
||||
starts_at: Visible à partir de
|
||||
ends_at: Visible jusqu'à
|
||||
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
|
||||
action_url: Lien (facultatif)
|
||||
action_url_hint: Pour une mise à jour, une URL vide ouvre le store. Sinon une URL https.
|
||||
action_label: Libellé du bouton
|
||||
dismissible: L'utilisateur peut fermer l'alerte
|
||||
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
|
||||
submit_create: Enregistrer l'alerte
|
||||
submit_update: Mettre à jour l'alerte
|
||||
cancel: Annuler
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ it:
|
||||
nav:
|
||||
dashboard: Dashboard
|
||||
ops: Ops
|
||||
announcements: Avvisi
|
||||
clubs: Società e squadre
|
||||
billing: Fatturazione
|
||||
youtube: YouTube
|
||||
@@ -49,6 +50,9 @@ it:
|
||||
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
|
||||
transfer_cancelled: Bonifico in attesa annullato.
|
||||
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
|
||||
announcement_created: Avviso salvato
|
||||
announcement_updated: Avviso aggiornato
|
||||
announcement_destroyed: Avviso eliminato
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Sì"
|
||||
@@ -61,7 +65,7 @@ it:
|
||||
username_label: Username
|
||||
password_label: Password
|
||||
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:
|
||||
edit:
|
||||
title: Cambia password
|
||||
@@ -309,6 +313,8 @@ it:
|
||||
club: Società
|
||||
plan: Piano
|
||||
amount: Importo
|
||||
holder: Intestatario
|
||||
iban: IBAN
|
||||
causal: Causale
|
||||
requested: Richiesto
|
||||
confirm_button: Conferma pagamento e attiva
|
||||
@@ -396,3 +402,64 @@ it:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replay — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Avvisi
|
||||
lead: Messaggi verso le app, il sito pubblico e l'area riservata — manutenzione, aggiornamenti o comunicazioni.
|
||||
new: Nuovo avviso
|
||||
table_title: Avvisi
|
||||
none: Nessun avviso. Crea il primo per notificare app e sito.
|
||||
new:
|
||||
title: Nuovo avviso
|
||||
edit:
|
||||
title: Modifica avviso
|
||||
table:
|
||||
status: Stato
|
||||
kind: Tipo
|
||||
title: Messaggio
|
||||
channels: Destinazioni
|
||||
window: Finestra
|
||||
actions:
|
||||
edit: Modifica
|
||||
delete: Elimina
|
||||
delete_confirm: Eliminare questo avviso? Non sarà più visibile nelle destinazioni scelte.
|
||||
kind:
|
||||
info: Informazione
|
||||
maintenance: Manutenzione
|
||||
update: Aggiornamento app
|
||||
severity:
|
||||
info: Info
|
||||
warning: Attenzione
|
||||
critical: Critico
|
||||
status:
|
||||
draft: Bozza
|
||||
published: Pubblicato
|
||||
archived: Archiviato
|
||||
channels:
|
||||
app: App (Android e iOS)
|
||||
web_public: Sito pubblico
|
||||
web_private: Area riservata
|
||||
window:
|
||||
always: Sempre (finché pubblicato)
|
||||
from: "Da %{time}"
|
||||
until: "Fino a %{time}"
|
||||
form:
|
||||
kind: Tipo
|
||||
severity: Gravità
|
||||
status: Stato
|
||||
channels: Dove mostrarlo
|
||||
channels_hint: Puoi selezionare una o più destinazioni. L'area riservata è visibile dopo il login (società, squadre, account).
|
||||
title: Titolo
|
||||
body: Testo
|
||||
starts_at: Inizio visibilità
|
||||
ends_at: Fine visibilità
|
||||
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
|
||||
action_url: Link (facoltativo)
|
||||
action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store. Altrimenti un URL https.
|
||||
action_label: Etichetta del pulsante
|
||||
dismissible: L'utente può chiudere l'avviso
|
||||
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
|
||||
submit_create: Salva avviso
|
||||
submit_update: Aggiorna avviso
|
||||
cancel: Annulla
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ de:
|
||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||
app_announcement:
|
||||
no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
|
||||
club:
|
||||
back_to_club: "← Verein"
|
||||
sport_label: Hauptsportart
|
||||
@@ -83,11 +85,9 @@ de:
|
||||
first_team_name_placeholder: "z. B. Under 15"
|
||||
plan_label: "Starttarif (für den gesamten Verein)"
|
||||
plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Premium-Abrechnung
|
||||
interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
|
||||
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
|
||||
plan_light: "Premium Light — 59 €/Jahr"
|
||||
plan_full: "Premium Full — 199 €/Jahr"
|
||||
plan_hint: "Karte, Überweisung oder monatliche Abrechnung wählst du danach auf der Abo-Seite. Ein kostenpflichtiger Tarif wird erst nach der Zahlung aktiv."
|
||||
submit: Verein erstellen
|
||||
billing:
|
||||
title: "Abonnement — %{name}"
|
||||
@@ -193,6 +193,10 @@ de:
|
||||
contact_trailer: für ein maßgeschneidertes Angebot.
|
||||
club_payments_link: Zahlungen und Rechnungen des Vereins
|
||||
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_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
|
||||
@@ -489,6 +493,7 @@ de:
|
||||
modal:
|
||||
set_won_title: Satz gewonnen
|
||||
confirm: Satz beenden
|
||||
confirm_action: Bestätigen
|
||||
cancel: Abbrechen
|
||||
board:
|
||||
next_period: Nächstes Viertel
|
||||
@@ -528,6 +533,17 @@ de:
|
||||
mute_error: Fehler beim Stummschalten
|
||||
close_error: Fehler beim 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_copied: Link kopiert
|
||||
copy_prompt: "Link kopieren:"
|
||||
@@ -613,6 +629,7 @@ de:
|
||||
clubs:
|
||||
already_registered: Du hast bereits einen Verein registriert
|
||||
created: Verein und erstes Team erstellt.
|
||||
created_complete_subscription: Verein erstellt. Schließe das Abo auf dieser Seite ab.
|
||||
updated: Verein aktualisiert.
|
||||
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."
|
||||
@@ -685,6 +702,13 @@ de:
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
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:
|
||||
name_required: Der Name ist erforderlich
|
||||
profile_updated: Profil aktualisiert.
|
||||
|
||||
@@ -16,6 +16,8 @@ en:
|
||||
too_short: "is too short (minimum is %{count} characters)"
|
||||
too_long: "is too long (maximum is %{count} characters)"
|
||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||
app_announcement:
|
||||
no_channel: Select at least one destination (apps, public website or logged-in area).
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Main sport
|
||||
@@ -33,6 +35,16 @@ en:
|
||||
quick_colors: Quick colors
|
||||
header_preview_label: Header preview
|
||||
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:
|
||||
title: "%{name} — Club"
|
||||
plan_label: "Plan:"
|
||||
@@ -78,11 +90,9 @@ en:
|
||||
first_team_name_placeholder: "e.g. Under 15"
|
||||
plan_label: "Starting plan (for the whole club)"
|
||||
plan_free: "Free — 1 transmitter account per team, 1 live stream"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Premium billing
|
||||
interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
|
||||
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
|
||||
plan_light: "Premium Light — €59/year"
|
||||
plan_full: "Premium Full — €199/year"
|
||||
plan_hint: "Card, bank transfer or monthly billing are chosen later on the Subscription page. A paid plan activates only after payment."
|
||||
submit: Create club
|
||||
billing:
|
||||
title: "Subscription — %{name}"
|
||||
@@ -188,6 +198,10 @@ en:
|
||||
contact_trailer: for a custom offer.
|
||||
club_payments_link: Club payments and invoices
|
||||
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_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
|
||||
@@ -484,6 +498,7 @@ en:
|
||||
modal:
|
||||
set_won_title: Set won
|
||||
confirm: Close set
|
||||
confirm_action: Confirm
|
||||
cancel: Cancel
|
||||
board:
|
||||
next_period: Next quarter
|
||||
@@ -523,6 +538,17 @@ en:
|
||||
mute_error: Error muting audio
|
||||
close_error: Error closing
|
||||
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_copied: Link copied
|
||||
copy_prompt: "Copy the link:"
|
||||
@@ -608,6 +634,7 @@ en:
|
||||
clubs:
|
||||
already_registered: You've already registered a club
|
||||
created: Club and first team created.
|
||||
created_complete_subscription: Club created. Complete your subscription on this page.
|
||||
updated: Club updated.
|
||||
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."
|
||||
@@ -680,6 +707,13 @@ en:
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords don't match
|
||||
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:
|
||||
name_required: Name is required
|
||||
profile_updated: Profile updated.
|
||||
|
||||
@@ -21,6 +21,8 @@ es:
|
||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
app_announcement:
|
||||
no_channel: Elige al menos un destino (apps, sitio público o área privada).
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Deporte principal
|
||||
@@ -83,11 +85,9 @@ es:
|
||||
first_team_name_placeholder: "ej. Sub-15"
|
||||
plan_label: "Plan inicial (para todo el club)"
|
||||
plan_free: "Free — 1 cuenta de emisión por equipo, 1 directo"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Facturación premium
|
||||
interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
|
||||
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
|
||||
plan_light: "Premium Light — 59 €/año"
|
||||
plan_full: "Premium Full — 199 €/año"
|
||||
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."
|
||||
submit: Crear club
|
||||
billing:
|
||||
title: "Suscripción — %{name}"
|
||||
@@ -193,6 +193,10 @@ es:
|
||||
contact_trailer: para una oferta a medida.
|
||||
club_payments_link: Pagos y facturas del club
|
||||
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_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
|
||||
@@ -489,6 +493,7 @@ es:
|
||||
modal:
|
||||
set_won_title: Set ganado
|
||||
confirm: Cerrar set
|
||||
confirm_action: Confirmar
|
||||
cancel: Cancelar
|
||||
board:
|
||||
next_period: Siguiente cuarto
|
||||
@@ -528,6 +533,17 @@ es:
|
||||
mute_error: Error al silenciar el audio
|
||||
close_error: Error al cerrar
|
||||
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_copied: Enlace copiado
|
||||
copy_prompt: "Copia el enlace:"
|
||||
@@ -613,6 +629,7 @@ es:
|
||||
clubs:
|
||||
already_registered: Ya has registrado un club
|
||||
created: Club y primer equipo creados.
|
||||
created_complete_subscription: Club creado. Completa la suscripción en esta página.
|
||||
updated: Club actualizado.
|
||||
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."
|
||||
@@ -685,6 +702,13 @@ es:
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
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:
|
||||
name_required: El nombre es obligatorio
|
||||
profile_updated: Perfil actualizado.
|
||||
|
||||
@@ -21,6 +21,8 @@ fr:
|
||||
too_short: "est trop court (minimum %{count} caractères)"
|
||||
too_long: "est trop long (maximum %{count} caractères)"
|
||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
app_announcement:
|
||||
no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Sport principal
|
||||
@@ -83,11 +85,9 @@ fr:
|
||||
first_team_name_placeholder: "ex. Under 15"
|
||||
plan_label: "Forfait de départ (pour tout le club)"
|
||||
plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Facturation premium
|
||||
interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
|
||||
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
|
||||
plan_light: "Premium Light — 59 €/an"
|
||||
plan_full: "Premium Full — 199 €/an"
|
||||
plan_hint: "Carte, virement ou mensualité se choisissent ensuite, sur la page Abonnement. Le forfait payant ne s'active qu'après le paiement."
|
||||
submit: Créer le club
|
||||
billing:
|
||||
title: "Abonnement — %{name}"
|
||||
@@ -193,6 +193,10 @@ fr:
|
||||
contact_trailer: pour une offre sur mesure.
|
||||
club_payments_link: Paiements et factures du club
|
||||
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> : c’est avec lui que tu te connectes à l’app 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 d’un second compte)."
|
||||
extra_staff_heading: Autres comptes autorisés à diffuser
|
||||
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_assign_submit: C'est moi — responsable diffusion
|
||||
@@ -489,6 +493,7 @@ fr:
|
||||
modal:
|
||||
set_won_title: Set gagné
|
||||
confirm: Terminer le set
|
||||
confirm_action: Confirmer
|
||||
cancel: Annuler
|
||||
board:
|
||||
next_period: Quart-temps suivant
|
||||
@@ -528,6 +533,17 @@ fr:
|
||||
mute_error: Erreur lors de la coupure audio
|
||||
close_error: Erreur de clôture
|
||||
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_copied: Lien copié
|
||||
copy_prompt: "Copiez le lien :"
|
||||
@@ -613,6 +629,7 @@ fr:
|
||||
clubs:
|
||||
already_registered: Tu as déjà inscrit un club
|
||||
created: Club et première équipe créés.
|
||||
created_complete_subscription: Club créé. Termine l'abonnement sur cette page.
|
||||
updated: Club mis à jour.
|
||||
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."
|
||||
@@ -685,6 +702,13 @@ fr:
|
||||
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_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:
|
||||
name_required: Le nom est obligatoire
|
||||
profile_updated: Profil mis à jour.
|
||||
|
||||
@@ -16,6 +16,8 @@ it:
|
||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||
app_announcement:
|
||||
no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
|
||||
club:
|
||||
back_to_club: "← Società"
|
||||
sport_label: Sport principale
|
||||
@@ -33,6 +35,16 @@ it:
|
||||
quick_colors: Colori rapidi
|
||||
header_preview_label: Anteprima intestazione
|
||||
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:
|
||||
title: "%{name} — Società"
|
||||
plan_label: "Piano:"
|
||||
@@ -78,11 +90,9 @@ it:
|
||||
first_team_name_placeholder: "es. Under 15"
|
||||
plan_label: "Piano iniziale (per tutta la società)"
|
||||
plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
|
||||
plan_light: Premium Light
|
||||
plan_full: Premium Full
|
||||
interval_label: Fatturazione premium
|
||||
interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
|
||||
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
|
||||
plan_light: "Premium Light — €59/anno"
|
||||
plan_full: "Premium Full — €199/anno"
|
||||
plan_hint: "Carta, bonifico o cadenza mensile li scegli dopo, nella pagina Abbonamento. Il piano a pagamento si attiva solo dopo il pagamento."
|
||||
submit: Crea società
|
||||
billing:
|
||||
title: "Abbonamento — %{name}"
|
||||
@@ -188,6 +198,10 @@ it:
|
||||
contact_trailer: per un'offerta su misura.
|
||||
club_payments_link: Pagamenti e fatture della società
|
||||
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_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
|
||||
@@ -330,7 +344,7 @@ it:
|
||||
sdi_or_pec: "Codice destinatario SDI (7 caratteri) o PEC"
|
||||
sdi_invalid: "Codice destinatario SDI (7 caratteri)"
|
||||
actions:
|
||||
current_plan: Piano attuale
|
||||
current_plan: Piano attivo
|
||||
contact_for_free: "Per passare al piano Free, contatta il supporto."
|
||||
stripe_not_configured: Stripe non configurato
|
||||
stripe_prices_not_configured: Prezzi Stripe non configurati
|
||||
@@ -436,13 +450,15 @@ it:
|
||||
pay_button: Paga con bonifico
|
||||
pay_button_with_price: "Paga con bonifico — %{price}"
|
||||
or_label: oppure
|
||||
pending_title: Bonifico in attesa di conferma
|
||||
pending_body_html: "Hai richiesto <strong>%{plan}</strong> (%{price}). Il piano si attiverà dopo la conferma del pagamento. Causale: <strong>%{causal}</strong>."
|
||||
pending_proof_html: "Invia la distinta a %{email_link}."
|
||||
pending_title: Ordine in corso
|
||||
pending_body_html: "Hai richiesto <strong>%{plan}</strong> (%{price}). Resta in attesa: il piano si attiverà quando Match Live TV confermerà il pagamento."
|
||||
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_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}"
|
||||
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."
|
||||
regia:
|
||||
meta_title_fallback: "Regia — Match Live TV"
|
||||
@@ -503,6 +519,7 @@ it:
|
||||
modal:
|
||||
set_won_title: Set vinto
|
||||
confirm: Chiudi set
|
||||
confirm_action: Conferma
|
||||
cancel: Annulla
|
||||
board:
|
||||
next_period: Prossimo quarto
|
||||
@@ -542,6 +559,17 @@ it:
|
||||
mute_error: Errore silenziamento audio
|
||||
close_error: Errore chiusura
|
||||
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_copied: Link copiato
|
||||
copy_prompt: "Copia il link:"
|
||||
@@ -627,6 +655,7 @@ it:
|
||||
clubs:
|
||||
already_registered: Hai già registrato una società
|
||||
created: Società e prima squadra create.
|
||||
created_complete_subscription: Società creata. Completa l'abbonamento in questa pagina.
|
||||
updated: Società aggiornata.
|
||||
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."
|
||||
@@ -646,7 +675,7 @@ it:
|
||||
subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free."
|
||||
stripe_error: "Errore Stripe: %{message}"
|
||||
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."
|
||||
club_recordings:
|
||||
not_authorized: Non autorizzato
|
||||
@@ -704,6 +733,13 @@ it:
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_mismatch: Le password non coincidono
|
||||
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:
|
||||
name_required: Il nome è obbligatorio
|
||||
profile_updated: Profilo aggiornato.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
en:
|
||||
coverable:
|
||||
errors:
|
||||
invalid_type: "must be PNG, JPEG or WebP"
|
||||
too_large: "exceeds the maximum size of 5 MB"
|
||||
@@ -0,0 +1,5 @@
|
||||
it:
|
||||
coverable:
|
||||
errors:
|
||||
invalid_type: "deve essere PNG, JPEG o WebP"
|
||||
too_large: "supera la dimensione massima di 5 MB"
|
||||
@@ -28,6 +28,7 @@ de:
|
||||
s3_item4_html: "<strong>Audiovisuelle Inhalte:</strong> Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
|
||||
s3_item5_html: "<strong>Zahlungsdaten:</strong> Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
|
||||
s3_item6_html: "<strong>Nutzungsdaten:</strong> Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
|
||||
s3_item7_html: "<strong>Daten des Kontaktformulars:</strong> Name, E-Mail, optionaler Verein, Anliegen und Nachricht von der Kontaktseite."
|
||||
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
|
||||
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare <strong>minderjährige Athleten</strong> erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
|
||||
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
|
||||
@@ -56,6 +57,8 @@ de:
|
||||
s5_row5_basis: Vertragserfüllung
|
||||
s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung)
|
||||
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_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;
|
||||
@@ -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_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 30–90 Tage), sofern keine Sicherheitspflichten entgegenstehen."
|
||||
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_intro: "Als betroffene Person haben Sie das Recht:"
|
||||
s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie;
|
||||
|
||||
@@ -28,6 +28,7 @@ en:
|
||||
s3_item4_html: "<strong>Audiovisual content:</strong> video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
|
||||
s3_item5_html: "<strong>Payment data:</strong> for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
|
||||
s3_item6_html: "<strong>Browsing data:</strong> server logs, IP address, user agent, needed for the security and operation of the site."
|
||||
s3_item7_html: "<strong>Contact form data:</strong> name, email, optional club, topic and message submitted from the Contact page."
|
||||
s4_title: 4. Processing of images and data of minor athletes
|
||||
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable <strong>minor athletes</strong> (image, voice, jersey number, game data)."
|
||||
s4_lead1: Who is responsible for processing images of minors
|
||||
@@ -56,6 +57,8 @@ en:
|
||||
s5_row5_basis: Performance of a contract
|
||||
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
|
||||
s5_row6_basis: Consent or legitimate interest, depending on the case
|
||||
s5_row7_purpose: Handling requests sent via the contact form
|
||||
s5_row7_basis: Pre-contractual measures; legitimate interest in answering enquiries
|
||||
s6_title: 6. Recipients and data processors
|
||||
s6_intro: "Data may be shared with providers acting as data processors, including:"
|
||||
s6_item1: hosting and cloud/server infrastructure provider;
|
||||
@@ -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_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 30–90 days), unless security obligations require otherwise."
|
||||
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_intro: "As a data subject you have the right to:"
|
||||
s9_item1: access your data and obtain a copy of it;
|
||||
|
||||
@@ -28,6 +28,7 @@ es:
|
||||
s3_item4_html: "<strong>Contenidos audiovisuales:</strong> flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
|
||||
s3_item5_html: "<strong>Datos de pago:</strong> para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
|
||||
s3_item6_html: "<strong>Datos de navegación:</strong> registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
|
||||
s3_item7_html: "<strong>Datos del formulario de contacto:</strong> nombre, email, club opcional, motivo y mensaje enviados desde la página de Contacto."
|
||||
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
|
||||
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a <strong>deportistas menores de edad</strong> identificables (imagen, voz, número de camiseta, datos de juego)."
|
||||
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
|
||||
@@ -56,6 +57,8 @@ es:
|
||||
s5_row5_basis: Ejecución del contrato
|
||||
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_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_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;
|
||||
@@ -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_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_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_intro: "Como interesado tienes derecho a:"
|
||||
s9_item1: acceder a tus datos y obtener una copia de ellos;
|
||||
|
||||
@@ -28,6 +28,7 @@ fr:
|
||||
s3_item4_html: "<strong>Contenus audiovisuels :</strong> flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
|
||||
s3_item5_html: "<strong>Données de paiement :</strong> pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
|
||||
s3_item6_html: "<strong>Données de navigation :</strong> journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
|
||||
s3_item7_html: "<strong>Données du formulaire de contact :</strong> nom, email, club éventuel, motif et message envoyés depuis la page Contact."
|
||||
s4_title: 4. Traitement des images et données des athlètes mineurs
|
||||
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des <strong>athlètes mineurs</strong> identifiables (image, voix, numéro de maillot, données de jeu)."
|
||||
s4_lead1: Qui est responsable du traitement des images des mineurs
|
||||
@@ -56,6 +57,8 @@ fr:
|
||||
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_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_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 ;
|
||||
@@ -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_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_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_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 ;
|
||||
|
||||
@@ -28,6 +28,7 @@ it:
|
||||
s3_item4_html: "<strong>Contenuti audiovisivi:</strong> flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato."
|
||||
s3_item5_html: "<strong>Dati di pagamento:</strong> per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta."
|
||||
s3_item6_html: "<strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
|
||||
s3_item7_html: "<strong>Dati del modulo contatti:</strong> nome, email, eventuale società, motivo e messaggio inviati dalla pagina Contatti."
|
||||
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
|
||||
s4_p1_html: "Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18). Le dirette possono riprendere <strong>atleti minorenni</strong> identificabili (immagine, voce, numero di maglia, dati di gioco)."
|
||||
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
|
||||
@@ -56,6 +57,8 @@ it:
|
||||
s5_row5_basis: Esecuzione del contratto
|
||||
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
|
||||
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
|
||||
s5_row7_purpose: Gestione delle richieste inviate dal modulo contatti
|
||||
s5_row7_basis: Misure precontrattuali; legittimo interesse a rispondere alle richieste
|
||||
s6_title: 6. Destinatari e responsabili del trattamento
|
||||
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
|
||||
s6_item1: fornitore di hosting e infrastruttura cloud/server;
|
||||
@@ -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_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 30–90 giorni) salvo obblighi di sicurezza."
|
||||
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_intro: "In qualità di interessato hai diritto di:"
|
||||
s9_item1: accedere ai tuoi dati e ottenerne copia;
|
||||
|
||||
@@ -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."
|
||||
ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.
|
||||
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:
|
||||
subject: "Replay bereit — %{title}"
|
||||
hello: "Hallo %{name},"
|
||||
|
||||
@@ -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."
|
||||
ignore: If you did not request this reset, ignore this 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:
|
||||
subject: "Replay ready — %{title}"
|
||||
hello: "Hi %{name},"
|
||||
|
||||
@@ -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á."
|
||||
ignore: Si no solicitaste el restablecimiento, ignora este correo.
|
||||
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:
|
||||
subject: "Replay listo — %{title}"
|
||||
hello: "Hola %{name},"
|
||||
|
||||
@@ -9,6 +9,23 @@ fr:
|
||||
expiry_html: "Le lien est valable %{hours} heures. Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié."
|
||||
ignore: Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail.
|
||||
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:
|
||||
subject: "Replay prêt — %{title}"
|
||||
hello: "Bonjour %{name},"
|
||||
|
||||
@@ -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."
|
||||
ignore: Se non hai richiesto tu il reset, ignora questa 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:
|
||||
subject: "Replay pronto — %{title}"
|
||||
hello: "Ciao %{name},"
|
||||
|
||||
@@ -175,3 +175,40 @@ de:
|
||||
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
|
||||
cta_start: Kostenlos starten
|
||||
cta_faq: Häufige Fragen
|
||||
contacts:
|
||||
meta_title: "Kontakt — Match Live TV"
|
||||
meta_description: "Kontakt zu Match Live TV: Angebote für Sportvereine, technischer Support und Datenschutzanfragen. Sitz in Mailand."
|
||||
eyebrow: Match Live TV
|
||||
title: Kontakt
|
||||
lead: Schreib uns für ein Angebot, App-Support oder eine Datenschutzanfrage. Wir antworten in der Regel innerhalb eines Werktags.
|
||||
response_time: "Typische Antwortzeit: 1 Werktag"
|
||||
commercial_title: Vertrieb
|
||||
commercial_body: Maßgeschneiderte Tarife, mehrere Teams, Turniere oder Integrationen. Schreib an
|
||||
support_title: Support
|
||||
support_body_html: "Probleme mit Zugang, Livestream oder App. Sieh zuerst die %{faq_link} oder die Seite %{support_link} an oder schreib an"
|
||||
support_link: Support
|
||||
faq_link: FAQ
|
||||
privacy_title: Datenschutz
|
||||
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
|
||||
company_title: Sitz
|
||||
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
|
||||
label_address: Adresse
|
||||
label_vat: USt-IdNr.
|
||||
form_title: Nachricht senden
|
||||
form_lead: "Fülle das Formular aus: Wir antworten an die angegebene E-Mail-Adresse."
|
||||
name_label: Name
|
||||
email_label: E-Mail
|
||||
club_label: Sportverein (optional)
|
||||
topic_label: Anliegen
|
||||
topic_commercial: Vertrieb / Angebot
|
||||
topic_support: Technischer Support
|
||||
topic_privacy: Datenschutz / DSGVO
|
||||
message_label: Nachricht
|
||||
honeypot_label: Website
|
||||
privacy_accept_html: Ich stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu, wie in der %{privacy_link} beschrieben.
|
||||
privacy_link: Datenschutzerklärung
|
||||
submit: Nachricht senden
|
||||
cta_title: Lieber selbst nachsehen?
|
||||
cta_body: Viele Antworten stehen bereits in den FAQ. Um mit dem Streamen zu starten, registriere den Verein.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Team registrieren
|
||||
|
||||
@@ -175,3 +175,40 @@ en:
|
||||
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
|
||||
cta_start: Start for free
|
||||
cta_faq: Frequently asked questions
|
||||
contacts:
|
||||
meta_title: "Contact — Match Live TV"
|
||||
meta_description: "Contact Match Live TV: quotes for sports clubs, technical support and privacy requests. Based in Milan."
|
||||
eyebrow: Match Live TV
|
||||
title: Contact
|
||||
lead: Write to us for a quote, app support or a privacy request. We usually reply within one working day.
|
||||
response_time: "Typical reply time: 1 working day"
|
||||
commercial_title: Sales
|
||||
commercial_body: Custom plans, multiple teams, tournaments or integrations. Write to
|
||||
support_title: Support
|
||||
support_body_html: "Sign-in, live stream or app issues. Check the %{faq_link} or the %{support_link} page first, or write to"
|
||||
support_link: Support
|
||||
faq_link: FAQ
|
||||
privacy_title: Privacy
|
||||
privacy_body: GDPR rights, consent and personal data. Write to
|
||||
company_title: Company details
|
||||
company_lead: Service provider and data controller.
|
||||
label_address: Address
|
||||
label_vat: VAT number
|
||||
form_title: Send a message
|
||||
form_lead: "Fill in the form and we will reply to the email you provide."
|
||||
name_label: Name
|
||||
email_label: Email
|
||||
club_label: Sports club (optional)
|
||||
topic_label: Topic
|
||||
topic_commercial: Sales / quote
|
||||
topic_support: Technical support
|
||||
topic_privacy: Privacy / GDPR
|
||||
message_label: Message
|
||||
honeypot_label: Website
|
||||
privacy_accept_html: I agree to the processing of my data to handle this request, as described in the %{privacy_link}.
|
||||
privacy_link: privacy notice
|
||||
submit: Send message
|
||||
cta_title: Prefer to look it up yourself?
|
||||
cta_body: Many answers are already in the FAQ. To start streaming, register your club.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Register your team
|
||||
|
||||
@@ -175,3 +175,40 @@ es:
|
||||
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
|
||||
cta_start: Empieza gratis
|
||||
cta_faq: Preguntas frecuentes
|
||||
contacts:
|
||||
meta_title: "Contacto — Match Live TV"
|
||||
meta_description: "Contacta con Match Live TV: presupuestos para clubes deportivos, soporte técnico y solicitudes de privacidad. Sede en Milán."
|
||||
eyebrow: Match Live TV
|
||||
title: Contacto
|
||||
lead: Escríbenos para un presupuesto, para ayuda con la app o para una solicitud de privacidad. Solemos responder en un día laborable.
|
||||
response_time: "Tiempo de respuesta orientativo: 1 día laborable"
|
||||
commercial_title: Comercial
|
||||
commercial_body: Planes a medida, varios equipos, torneos o integraciones. Escribe a
|
||||
support_title: Asistencia
|
||||
support_body_html: "Problemas de acceso, directos o app. Consulta primero las %{faq_link} o la página %{support_link}, o escribe a"
|
||||
support_link: Soporte
|
||||
faq_link: FAQ
|
||||
privacy_title: Privacidad
|
||||
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
|
||||
company_title: Sede
|
||||
company_lead: Prestador del servicio y responsable del tratamiento.
|
||||
label_address: Dirección
|
||||
label_vat: NIF / IVA
|
||||
form_title: Enviar un mensaje
|
||||
form_lead: "Completa el formulario: te responderemos al email que indiques."
|
||||
name_label: Nombre
|
||||
email_label: Email
|
||||
club_label: Club deportivo (opcional)
|
||||
topic_label: Motivo
|
||||
topic_commercial: Comercial / presupuesto
|
||||
topic_support: Asistencia técnica
|
||||
topic_privacy: Privacidad / RGPD
|
||||
message_label: Mensaje
|
||||
honeypot_label: Sitio web
|
||||
privacy_accept_html: Acepto el tratamiento de los datos para gestionar esta solicitud, como se describe en la %{privacy_link}.
|
||||
privacy_link: política de privacidad
|
||||
submit: Enviar mensaje
|
||||
cta_title: "¿Prefieres buscarlo tú?"
|
||||
cta_body: Muchas respuestas ya están en las FAQ. Para empezar a emitir, registra el club.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Registrar el equipo
|
||||
|
||||
@@ -175,3 +175,40 @@ fr:
|
||||
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
|
||||
cta_start: Commencer gratuitement
|
||||
cta_faq: Questions fréquentes
|
||||
contacts:
|
||||
meta_title: "Contact — Match Live TV"
|
||||
meta_description: "Contactez Match Live TV : devis pour clubs sportifs, assistance technique et demandes privacy. Siège à Milan."
|
||||
eyebrow: Match Live TV
|
||||
title: Contact
|
||||
lead: Écrivez-nous pour un devis, pour l'assistance de l'app ou pour une demande privacy. Nous répondons en général sous un jour ouvrable.
|
||||
response_time: "Délai indicatif : 1 jour ouvrable"
|
||||
commercial_title: Commercial
|
||||
commercial_body: Offres sur mesure, plusieurs équipes, tournois ou intégrations. Écrivez à
|
||||
support_title: Assistance
|
||||
support_body_html: "Problèmes d'accès, de direct ou d'application. Consultez d'abord les %{faq_link} ou la page %{support_link}, ou écrivez à"
|
||||
support_link: Support
|
||||
faq_link: FAQ
|
||||
privacy_title: Confidentialité
|
||||
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
|
||||
company_title: Siège
|
||||
company_lead: Prestataire du service et responsable du traitement.
|
||||
label_address: Adresse
|
||||
label_vat: N° de TVA
|
||||
form_title: Envoyer un message
|
||||
form_lead: "Remplissez le formulaire : nous vous répondons à l'email indiqué."
|
||||
name_label: Nom
|
||||
email_label: Email
|
||||
club_label: Club sportif (facultatif)
|
||||
topic_label: Motif
|
||||
topic_commercial: Commercial / devis
|
||||
topic_support: Assistance technique
|
||||
topic_privacy: Confidentialité / RGPD
|
||||
message_label: Message
|
||||
honeypot_label: Site web
|
||||
privacy_accept_html: J'accepte le traitement des données pour gérer cette demande, comme décrit dans la %{privacy_link}.
|
||||
privacy_link: notice de confidentialité
|
||||
submit: Envoyer le message
|
||||
cta_title: Vous préférez chercher vous-même ?
|
||||
cta_body: Beaucoup de réponses sont déjà dans la FAQ. Pour commencer à diffuser, inscrivez le club.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Enregistrer l'équipe
|
||||
|
||||
@@ -175,3 +175,40 @@ it:
|
||||
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
|
||||
cta_start: Inizia gratis
|
||||
cta_faq: Domande frequenti
|
||||
contacts:
|
||||
meta_title: "Contatti — Match Live TV"
|
||||
meta_description: "Contatta Match Live TV: preventivi per società sportive, assistenza tecnica e richieste privacy. Sede a Milano."
|
||||
eyebrow: Match Live TV
|
||||
title: Contatti
|
||||
lead: Scrivici per un preventivo, per l'assistenza sull'app o per una richiesta privacy. Rispondiamo in genere entro un giorno lavorativo.
|
||||
response_time: "Risposta indicativa: 1 giorno lavorativo"
|
||||
commercial_title: Commerciale
|
||||
commercial_body: Piani su misura, più squadre, tornei o integrazioni. Scrivi a
|
||||
support_title: Assistenza
|
||||
support_body_html: "Problemi di accesso, dirette o app. Consulta prima le %{faq_link} o la pagina %{support_link}, oppure scrivi a"
|
||||
support_link: Supporto
|
||||
faq_link: FAQ
|
||||
privacy_title: Privacy
|
||||
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
|
||||
company_title: Sede
|
||||
company_lead: Titolare del servizio e del trattamento dei dati.
|
||||
label_address: Indirizzo
|
||||
label_vat: Partita IVA
|
||||
form_title: Invia un messaggio
|
||||
form_lead: "Compila il modulo: ti rispondiamo all'email che indichi."
|
||||
name_label: Nome
|
||||
email_label: Email
|
||||
club_label: Società sportiva (opzionale)
|
||||
topic_label: Motivo
|
||||
topic_commercial: Commerciale / preventivo
|
||||
topic_support: Assistenza tecnica
|
||||
topic_privacy: Privacy / GDPR
|
||||
message_label: Messaggio
|
||||
honeypot_label: Sito web
|
||||
privacy_accept_html: Acconsento al trattamento dei dati per gestire questa richiesta, come descritto nella %{privacy_link}.
|
||||
privacy_link: informativa privacy
|
||||
submit: Invia messaggio
|
||||
cta_title: Preferisci fare da solo?
|
||||
cta_body: Molte risposte sono già nelle FAQ. Per iniziare a trasmettere, registra la società.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Registra la squadra
|
||||
|
||||
@@ -141,6 +141,7 @@ de:
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
support: Support
|
||||
contacts: Kontakt
|
||||
cookies: Cookies
|
||||
terms: AGB
|
||||
pricing: Preise
|
||||
@@ -174,3 +175,7 @@ de:
|
||||
choice_youtube_hint_only: Entfernt nur das YouTube-Video
|
||||
choice_both_label: Website und YouTube
|
||||
choice_both_hint: Vollständige Löschung
|
||||
announcement:
|
||||
region: Hinweise
|
||||
dismiss: Hinweis schließen
|
||||
open: Öffnen
|
||||
|
||||
@@ -141,6 +141,7 @@ en:
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Support
|
||||
contacts: Contacts
|
||||
cookies: Cookies
|
||||
terms: Terms
|
||||
pricing: Pricing
|
||||
@@ -174,3 +175,7 @@ en:
|
||||
choice_youtube_hint_only: Removes only the YouTube video
|
||||
choice_both_label: Site and YouTube
|
||||
choice_both_hint: Complete deletion
|
||||
announcement:
|
||||
region: Notices
|
||||
dismiss: Dismiss notice
|
||||
open: Open
|
||||
|
||||
@@ -141,6 +141,7 @@ es:
|
||||
common:
|
||||
privacy: Privacidad
|
||||
support: Soporte
|
||||
contacts: Contacto
|
||||
cookies: Cookies
|
||||
terms: Términos
|
||||
pricing: Precios
|
||||
@@ -174,3 +175,7 @@ es:
|
||||
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
|
||||
choice_both_label: Sitio y YouTube
|
||||
choice_both_hint: Eliminación completa
|
||||
announcement:
|
||||
region: Avisos
|
||||
dismiss: Cerrar aviso
|
||||
open: Abrir
|
||||
|
||||
@@ -141,6 +141,7 @@ fr:
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
support: Support
|
||||
contacts: Contacts
|
||||
cookies: Cookies
|
||||
terms: Conditions
|
||||
pricing: Tarifs
|
||||
@@ -174,3 +175,7 @@ fr:
|
||||
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
|
||||
choice_both_label: Site et YouTube
|
||||
choice_both_hint: Suppression complète
|
||||
announcement:
|
||||
region: Alertes
|
||||
dismiss: Fermer l'alerte
|
||||
open: Ouvrir
|
||||
|
||||
@@ -141,6 +141,7 @@ it:
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Supporto
|
||||
contacts: Contatti
|
||||
cookies: Cookie
|
||||
terms: Termini
|
||||
pricing: Prezzi
|
||||
@@ -174,3 +175,7 @@ it:
|
||||
choice_youtube_hint_only: Rimuove solo il video YouTube
|
||||
choice_both_label: Da sito e YouTube
|
||||
choice_both_hint: Eliminazione completa
|
||||
announcement:
|
||||
region: Avvisi
|
||||
dismiss: Chiudi avviso
|
||||
open: Apri
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user