Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ed70b6999 | ||
|
|
bc2257efd2 | ||
|
|
4af4fa68ac | ||
|
|
45bdda7c95 | ||
|
|
b926531447 | ||
|
|
da6fc1d523 | ||
|
|
1d1cbf9f3f | ||
|
|
dd9901519a | ||
|
|
53449c5d3c | ||
|
|
db908e3109 | ||
|
|
5857f60d79 | ||
|
|
83a804a709 | ||
|
|
b8694a6b7b | ||
|
|
5daada81b0 | ||
|
|
d85bab30d1 | ||
|
|
7b3256c8a0 | ||
|
|
9bc89fceba | ||
|
|
d5ec266437 | ||
|
|
a448ad59ee | ||
|
|
74a156cedc | ||
|
|
97d787f758 |
@@ -9,13 +9,23 @@ module Admin
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = t("admin.flash.password_too_short")
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("admin.flash.password_mismatch")
|
||||
if (code = PasswordComplexity.violation(params[:password]))
|
||||
key = case code
|
||||
when :blank, :too_short then :password_too_short
|
||||
when :too_long then :password_too_long
|
||||
else :password_too_weak
|
||||
end
|
||||
flash.now[:alert] = t("admin.flash.#{key}")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(current_admin_account, params[:password])
|
||||
flash.now[:alert] = t("admin.flash.password_same_as_current")
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
module Api
|
||||
module V1
|
||||
class AccountsController < ApplicationController
|
||||
def show
|
||||
render json: user_json(current_user)
|
||||
end
|
||||
|
||||
def update
|
||||
name = params[:name].to_s.strip
|
||||
if name.blank?
|
||||
return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if current_user.update(name: name)
|
||||
render json: user_json(current_user)
|
||||
else
|
||||
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def password
|
||||
result = Users::ChangePassword.call(
|
||||
user: current_user,
|
||||
current_password: params[:current_password],
|
||||
password: params[:password],
|
||||
password_confirmation: params[:password_confirmation]
|
||||
)
|
||||
|
||||
unless result.ok?
|
||||
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render json: { message: I18n.t("flash.accounts.password_updated") }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_json(user)
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
end
|
||||
|
||||
def password_error_message(code)
|
||||
case code
|
||||
when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect")
|
||||
when :too_short then I18n.t("flash.accounts.password_too_short")
|
||||
when :too_long then I18n.t("flash.accounts.password_too_long")
|
||||
when :too_weak then I18n.t("flash.accounts.password_too_weak")
|
||||
when :same_as_current then I18n.t("flash.accounts.password_same_as_current")
|
||||
when :mismatch then I18n.t("flash.accounts.password_mismatch")
|
||||
else I18n.t("flash.accounts.password_update_failed")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
module Api
|
||||
module V1
|
||||
class AuthController < ApplicationController
|
||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
||||
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
|
||||
|
||||
def register
|
||||
user = User.new(
|
||||
@@ -22,18 +22,18 @@ module Api
|
||||
if user&.authenticate(params[:password])
|
||||
render json: token_response(user), status: :ok
|
||||
else
|
||||
render json: { error: "Invalid credentials" }, status: :unauthorized
|
||||
render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized
|
||||
end
|
||||
end
|
||||
|
||||
def logout
|
||||
render json: { message: "Logged out" }
|
||||
render json: { message: I18n.t("flash.sessions.logged_out") }
|
||||
end
|
||||
|
||||
def refresh
|
||||
payload = JsonWebToken.decode(params[:refresh_token] || bearer_token)
|
||||
user = User.find_by(id: payload&.dig(:user_id))
|
||||
return render json: { error: "Invalid token" }, status: :unauthorized unless user
|
||||
return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user
|
||||
|
||||
render json: token_response(user)
|
||||
end
|
||||
@@ -42,6 +42,13 @@ module Api
|
||||
render json: user_json(current_user)
|
||||
end
|
||||
|
||||
def forgot_password
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
render json: {
|
||||
message: I18n.t("flash.password_resets.email_sent")
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_response(user)
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
class ApplicationController < ActionController::API
|
||||
include ActionController::HttpAuthentication::Token::ControllerMethods
|
||||
|
||||
before_action :set_api_locale
|
||||
before_action :authenticate_request!
|
||||
|
||||
attr_reader :current_user
|
||||
|
||||
private
|
||||
|
||||
def set_api_locale
|
||||
explicit = LocaleResolver.normalize(request.headers["X-Locale"])
|
||||
I18n.locale = explicit ||
|
||||
LocaleResolver.from_accept_language(request.headers["Accept-Language"]) ||
|
||||
I18n.default_locale
|
||||
end
|
||||
|
||||
def authenticate_request!
|
||||
token = bearer_token
|
||||
payload = JsonWebToken.decode(token)
|
||||
@current_user = User.find_by(id: payload[:user_id]) if payload
|
||||
render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user
|
||||
render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user
|
||||
end
|
||||
|
||||
def bearer_token
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
module Public
|
||||
class AccountsController < WebBaseController
|
||||
before_action :require_login!
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
name = params[:name].to_s.strip
|
||||
if name.blank?
|
||||
flash.now[:alert] = t("flash.accounts.name_required")
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if current_user.update(name: name)
|
||||
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
|
||||
else
|
||||
flash.now[:alert] = current_user.errors.full_messages.to_sentence
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update_password
|
||||
result = Users::ChangePassword.call(
|
||||
user: current_user,
|
||||
current_password: params[:current_password],
|
||||
password: params[:password],
|
||||
password_confirmation: params[:password_confirmation]
|
||||
)
|
||||
|
||||
unless result.ok?
|
||||
redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}")
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -11,17 +11,8 @@ module Public
|
||||
load_club_billing_context_for_pricing
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
def support
|
||||
@app_store_review_chrome = true
|
||||
end
|
||||
|
||||
def privacy
|
||||
@@ -38,5 +29,18 @@ module Public
|
||||
|
||||
def pallavolo
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,11 +4,7 @@ module Public
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
||||
if user
|
||||
token = user.generate_password_reset!
|
||||
UserMailer.password_reset(user, token).deliver_now
|
||||
end
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
|
||||
redirect_to public_login_path,
|
||||
notice: t("flash.password_resets.email_sent")
|
||||
@@ -17,7 +13,7 @@ module Public
|
||||
def edit
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
redirect_to public_password_forgot_path,
|
||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||
return
|
||||
end
|
||||
@@ -27,19 +23,25 @@ module Public
|
||||
def update
|
||||
@user = User.find_by_password_reset_token(params[:token])
|
||||
if @user.nil? || @user.password_reset_expired?
|
||||
redirect_to new_public_password_reset_path,
|
||||
redirect_to public_password_forgot_path,
|
||||
alert: t("flash.password_resets.invalid_or_expired_link")
|
||||
return
|
||||
end
|
||||
|
||||
if params[:password].blank? || params[:password].length < 8
|
||||
flash.now[:alert] = t("flash.password_resets.password_min_length")
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if params[:password] != params[:password_confirmation]
|
||||
flash.now[:alert] = t("flash.password_resets.password_mismatch")
|
||||
if (code = PasswordComplexity.violation(params[:password]))
|
||||
flash.now[:alert] = t("flash.password_resets.password_#{code == :blank ? :too_short : code}")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(@user, params[:password])
|
||||
flash.now[:alert] = t("flash.password_resets.password_same_as_current")
|
||||
@token = params[:token]
|
||||
return render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@@ -11,6 +11,7 @@ module Public
|
||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/support", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/cookie", changefreq: "yearly", priority: "0.3" },
|
||||
{ loc: "#{base}/termini", changefreq: "yearly", priority: "0.3" }
|
||||
]
|
||||
|
||||
@@ -57,6 +57,10 @@ module Public
|
||||
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
|
||||
end
|
||||
|
||||
def plan_cta_name(plan)
|
||||
I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
|
||||
end
|
||||
|
||||
def billing_plan_change_info_lines(subscription: nil)
|
||||
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
class AdminAccount < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
has_secure_password
|
||||
|
||||
validates :username, presence: true, uniqueness: true
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Criteri "di mercato" (stile Cognito/Auth0 bilanciato):
|
||||
# - minimo 8 caratteri (max 72 per bcrypt)
|
||||
# - almeno 3 classi su 4: minuscole, maiuscole, numeri, simboli
|
||||
module PasswordComplexity
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
MIN_LENGTH = 8
|
||||
MAX_LENGTH = 72
|
||||
REQUIRED_CLASSES = 3
|
||||
|
||||
CLASS_CHECKS = {
|
||||
lowercase: /[a-z]/,
|
||||
uppercase: /[A-Z]/,
|
||||
digit: /\d/,
|
||||
symbol: /[^A-Za-z0-9]/
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
def violation(password)
|
||||
value = password.to_s
|
||||
return :blank if value.blank?
|
||||
return :too_short if value.length < MIN_LENGTH
|
||||
return :too_long if value.bytesize > MAX_LENGTH
|
||||
return :too_weak unless strong_enough?(value)
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def strong_enough?(password)
|
||||
matched = CLASS_CHECKS.count { |_, pattern| password.match?(pattern) }
|
||||
matched >= REQUIRED_CLASSES
|
||||
end
|
||||
|
||||
# Confronta con il digest già salvato (prima di assegnare la nuova password).
|
||||
def same_as_current?(record, password)
|
||||
return false if password.blank? || !record.respond_to?(:authenticate)
|
||||
|
||||
record.authenticate(password).present?
|
||||
end
|
||||
|
||||
def requirement_summary
|
||||
I18n.t("password_policy.hint")
|
||||
end
|
||||
end
|
||||
|
||||
included do
|
||||
validate :password_meets_complexity_policy, if: -> { password.present? }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def password_meets_complexity_policy
|
||||
case PasswordComplexity.violation(password)
|
||||
when :too_short
|
||||
errors.add(:password, :too_short, count: MIN_LENGTH)
|
||||
when :too_long
|
||||
errors.add(:password, :too_long, count: MAX_LENGTH)
|
||||
when :too_weak
|
||||
errors.add(:password, :complexity)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class User < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
ROLES = %w[admin coach parent volunteer].freeze
|
||||
|
||||
has_secure_password
|
||||
|
||||
@@ -5,9 +5,13 @@ module Billing
|
||||
|
||||
AMOUNT_HINTS = {
|
||||
500 => %w[premium_light monthly],
|
||||
790 => %w[premium_light monthly],
|
||||
4000 => %w[premium_light yearly],
|
||||
5900 => %w[premium_light yearly],
|
||||
2000 => %w[premium_full monthly],
|
||||
20_000 => %w[premium_full yearly]
|
||||
2490 => %w[premium_full monthly],
|
||||
20_000 => %w[premium_full yearly],
|
||||
19_900 => %w[premium_full yearly]
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
|
||||
@@ -23,10 +23,11 @@ module Billing
|
||||
|
||||
def button_label(plan:, interval:, kind:)
|
||||
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||
name = I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
|
||||
if kind == :checkout
|
||||
I18n.t("billing.messages.activate_button", plan: plan.name, price: price)
|
||||
I18n.t("billing.messages.activate_button", plan: name, price: price)
|
||||
else
|
||||
I18n.t("billing.messages.switch_button", plan: plan.name, price: price)
|
||||
I18n.t("billing.messages.switch_button", plan: name, price: price)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,9 +4,16 @@ module Billing
|
||||
INTERVALS = %w[monthly yearly].freeze
|
||||
DEFAULT_INTERVAL = "yearly"
|
||||
|
||||
PRICING = {
|
||||
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" },
|
||||
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" }
|
||||
# Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
|
||||
AMOUNTS = {
|
||||
"premium_light" => {
|
||||
"yearly" => { charge: 5900, list: 7900, equivalent_monthly: 492 },
|
||||
"monthly" => { charge: 790 }
|
||||
},
|
||||
"premium_full" => {
|
||||
"yearly" => { charge: 19_900, list: 24_900, equivalent_monthly: 1658 },
|
||||
"monthly" => { charge: 2490 }
|
||||
}
|
||||
}.freeze
|
||||
|
||||
class << self
|
||||
@@ -30,7 +37,23 @@ module Billing
|
||||
end
|
||||
|
||||
def label(plan_slug:, interval:)
|
||||
PRICING.dig(plan_slug.to_s, normalize_interval(interval)) || normalize_interval(interval)
|
||||
interval = normalize_interval(interval)
|
||||
format_interval_price(charge_cents(plan_slug, interval), interval)
|
||||
end
|
||||
|
||||
def list_label(plan_slug:, interval: "yearly")
|
||||
interval = normalize_interval(interval)
|
||||
cents = list_cents(plan_slug, interval)
|
||||
return nil if cents.blank?
|
||||
|
||||
format_interval_price(cents, interval)
|
||||
end
|
||||
|
||||
def equivalent_monthly_label(plan_slug:)
|
||||
cents = AMOUNTS.dig(plan_slug.to_s, "yearly", :equivalent_monthly)
|
||||
return nil if cents.blank?
|
||||
|
||||
format_interval_price(cents, "monthly")
|
||||
end
|
||||
|
||||
def available_intervals(plan_slug:)
|
||||
@@ -52,8 +75,33 @@ module Billing
|
||||
nil
|
||||
end
|
||||
|
||||
def format_eur(cents)
|
||||
cents = cents.to_i
|
||||
whole, frac = cents.divmod(100)
|
||||
if frac.zero?
|
||||
"€#{whole}"
|
||||
else
|
||||
sep = I18n.locale.to_s == "en" ? "." : ","
|
||||
"€#{whole}#{sep}#{frac.to_s.rjust(2, '0')}"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def charge_cents(plan_slug, interval)
|
||||
AMOUNTS.dig(plan_slug.to_s, interval, :charge)
|
||||
end
|
||||
|
||||
def list_cents(plan_slug, interval)
|
||||
AMOUNTS.dig(plan_slug.to_s, interval, :list)
|
||||
end
|
||||
|
||||
def format_interval_price(cents, interval)
|
||||
return nil if cents.blank?
|
||||
|
||||
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
|
||||
end
|
||||
|
||||
def price_id_for(plan_slug, interval)
|
||||
case [plan_slug, interval]
|
||||
when %w[premium_light monthly]
|
||||
|
||||
@@ -156,7 +156,7 @@ module Ops
|
||||
force_path_style: MatchLiveTv.replay_storage_force_path_style?
|
||||
)
|
||||
client.head_bucket(bucket: MatchLiveTv.replay_storage_bucket)
|
||||
ok_finding("garage_storage:ok", "Garage storage OK")
|
||||
ok_finding("garage_storage:head", "Garage storage OK")
|
||||
rescue StandardError => e
|
||||
fail_finding("garage_storage", "warning", "garage_storage:head", "Garage storage non raggiungibile", e.message)
|
||||
end
|
||||
|
||||
@@ -116,31 +116,30 @@ module Streams
|
||||
raise Error, "ffmpeg non disponibile: #{e.message}"
|
||||
end
|
||||
|
||||
# RTMP grezzo da telefono (overlay bruciato lato app); fallback HLS via proxy Rails.
|
||||
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono).
|
||||
# HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
|
||||
def youtube_ffmpeg_args(intake_source, output)
|
||||
mode, url = intake_source
|
||||
if mode == :rtmp
|
||||
return [
|
||||
common_head = [
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
"-fflags", "+genpts+discardcorrupt"
|
||||
]
|
||||
copy_tail = ["-c:v", "copy", "-c:a", "copy", "-f", "flv", output]
|
||||
|
||||
if mode == :rtmp
|
||||
return common_head + [
|
||||
"-analyzeduration", "10000000", "-probesize", "10000000",
|
||||
"-rw_timeout", "15000000",
|
||||
"-i", url,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-bsf:a", "aac_adtstoasc",
|
||||
"-f", "flv", output
|
||||
]
|
||||
"-i", url
|
||||
] + copy_tail
|
||||
end
|
||||
|
||||
[
|
||||
"ffmpeg", "-nostdin", "-hide_banner", "-loglevel", "warning",
|
||||
"-fflags", "+genpts+discardcorrupt",
|
||||
common_head + [
|
||||
"-rw_timeout", "15000000",
|
||||
"-live_start_index", "-1",
|
||||
"-i", url,
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "128k", "-ar", "48000", "-ac", "1",
|
||||
"-c:a", "copy",
|
||||
"-bsf:a", "aac_adtstoasc",
|
||||
"-f", "flv", output
|
||||
]
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
module Users
|
||||
class ChangePassword
|
||||
Result = Struct.new(:ok?, :error, keyword_init: true)
|
||||
|
||||
def self.call(user:, current_password:, password:, password_confirmation:)
|
||||
new(
|
||||
user: user,
|
||||
current_password: current_password,
|
||||
password: password,
|
||||
password_confirmation: password_confirmation
|
||||
).call
|
||||
end
|
||||
|
||||
def initialize(user:, current_password:, password:, password_confirmation:)
|
||||
@user = user
|
||||
@current_password = current_password.to_s
|
||||
@password = password.to_s
|
||||
@password_confirmation = password_confirmation.to_s
|
||||
end
|
||||
|
||||
def call
|
||||
unless @user.authenticate(@current_password)
|
||||
return Result.new(ok?: false, error: :current_incorrect)
|
||||
end
|
||||
|
||||
if @password != @password_confirmation
|
||||
return Result.new(ok?: false, error: :mismatch)
|
||||
end
|
||||
|
||||
if (code = PasswordComplexity.violation(@password))
|
||||
return Result.new(ok?: false, error: code == :blank ? :too_short : code)
|
||||
end
|
||||
|
||||
if PasswordComplexity.same_as_current?(@user, @password)
|
||||
return Result.new(ok?: false, error: :same_as_current)
|
||||
end
|
||||
|
||||
unless @user.update(password: @password)
|
||||
complexity_error = @user.errors.details[:password]&.any? { |d| d[:error] == :complexity }
|
||||
return Result.new(ok?: false, error: complexity_error ? :too_weak : :too_short)
|
||||
end
|
||||
|
||||
@user.clear_password_reset!
|
||||
Result.new(ok?: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
module Users
|
||||
class RequestPasswordReset
|
||||
def self.call(email:)
|
||||
new(email: email).call
|
||||
end
|
||||
|
||||
def initialize(email:)
|
||||
@email = email.to_s.downcase.strip
|
||||
end
|
||||
|
||||
def call
|
||||
user = User.find_by(email: @email)
|
||||
return if user.nil?
|
||||
|
||||
token = user.generate_password_reset!
|
||||
UserMailer.password_reset(user, token).deliver_now
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,17 +8,17 @@
|
||||
<%= 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=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=63">
|
||||
</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(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||
<main>
|
||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||
<script src="/branding-form.js?v=1" defer></script>
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=2" defer></script>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=44">
|
||||
<link rel="stylesheet" href="/marketing.css?v=63">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
<% elsif current_user.manageable_teams.any? %>
|
||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||
<% end %>
|
||||
· <%= link_to t("nav.account"), public_account_path %>
|
||||
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||
<% else %>
|
||||
· <%= link_to t("nav.login"), public_login_path %>
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
<% content_for :title, t("auth.account.meta_title") %>
|
||||
<% content_for :meta_description, t("auth.account.meta_description") %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1><%= t("auth.account.title") %></h1>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
|
||||
<%= form_with url: public_account_path, method: :patch, local: true do %>
|
||||
<p>
|
||||
<label for="account_email"><%= t("auth.email") %></label><br>
|
||||
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
|
||||
class="input" autocomplete="username"
|
||||
style="width:100%;opacity:0.75;cursor:not-allowed">
|
||||
</p>
|
||||
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
|
||||
<%= t("auth.account.role_label", role: current_user.role) %>
|
||||
</p>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :name,
|
||||
label: t("auth.account.name_label"),
|
||||
value: current_user.name,
|
||||
required: true,
|
||||
autocomplete: "name",
|
||||
masked: false %>
|
||||
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :current_password,
|
||||
label: t("auth.account.current_password_label"),
|
||||
input_type: "password",
|
||||
required: true,
|
||||
autocomplete: "current-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password,
|
||||
label: t("auth.account.new_password_label"),
|
||||
input_type: "password",
|
||||
required: true,
|
||||
autocomplete: "new-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password_confirmation,
|
||||
label: t("auth.password_confirmation"),
|
||||
input_type: "password",
|
||||
required: true,
|
||||
autocomplete: "new-password" %>
|
||||
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -13,7 +13,7 @@
|
||||
<% if @subscription.admin_comped_reason.present? %>
|
||||
(<%= @subscription.admin_comped_reason %>)
|
||||
<% end %>.
|
||||
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlive.it", "info@matchlive.it")) %>
|
||||
<%= raw t("club.billing.comped.no_payment_html", email_link: mail_to("info@matchlivetv.it", "info@matchlivetv.it")) %>
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
<% content_for :meta_description, t("auth.invitation.meta_description") %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<section class="auth-page">
|
||||
<div class="card">
|
||||
<h1><%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %></h1>
|
||||
<p><%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %></p>
|
||||
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||
@@ -23,4 +24,5 @@
|
||||
<%= t("auth.invitation.mobile_app_label") %>
|
||||
<a href="matchlivetv://join/<%= @token %>"><%= t("auth.invitation.open_in_app") %></a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
<h3><%= t("legal.cookies.s4_1_title") %></h3>
|
||||
<p><%= t("legal.cookies.s4_1_intro") %></p>
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
@@ -65,6 +66,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h3><%= t("legal.cookies.s4_2_title") %></h3>
|
||||
<p>
|
||||
@@ -75,6 +77,7 @@
|
||||
<% else %>
|
||||
<p class="muted"><%= t("legal.cookies.s4_2_inactive") %></p>
|
||||
<% end %>
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.cookies.table2_col_name") %></th><th><%= t("legal.cookies.table_col_purpose") %></th><th><%= t("legal.cookies.table_col_duration") %></th><th><%= t("legal.cookies.table_col_provider") %></th></tr>
|
||||
@@ -100,6 +103,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"legal.cookies.s4_2_p2_html",
|
||||
|
||||
@@ -2,63 +2,283 @@
|
||||
<% content_for :meta_description, t("pages.features.meta_description") %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_features_path) %>
|
||||
|
||||
<div class="wrap">
|
||||
<h1 style="margin-top:24px"><%= t("pages.features.title") %></h1>
|
||||
<p style="color:#aaa;max-width:640px"><%= t("pages.features.lead") %></p>
|
||||
<article class="features-page">
|
||||
<header class="features-hero">
|
||||
<div class="wrap features-hero__grid">
|
||||
<div class="features-hero__copy">
|
||||
<p class="features-hero__eyebrow">Match Live TV</p>
|
||||
<h1><%= t("pages.features.hero_title") %></h1>
|
||||
<p class="features-hero__lead"><%= t("pages.features.hero_lead") %></p>
|
||||
|
||||
<section class="section">
|
||||
<h2><%= t("pages.features.staff_section_title") %></h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.staff_invite_title") %></h3>
|
||||
<p><%= t("pages.features.staff_invite_body") %></p>
|
||||
<ol class="features-flow" aria-label="<%= t("pages.features.flow_aria") %>">
|
||||
<li class="features-flow__item">
|
||||
<span class="features-flow__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||
<span class="features-flow__label"><%= t("pages.features.flow_phone") %></span>
|
||||
</li>
|
||||
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
|
||||
<li class="features-flow__item">
|
||||
<span class="features-flow__icon features-flow__icon--blue" aria-hidden="true"><i class="fa-solid fa-user"></i></span>
|
||||
<span class="features-flow__label"><%= t("pages.features.flow_account") %></span>
|
||||
</li>
|
||||
<li class="features-flow__arrow" aria-hidden="true"><i class="fa-solid fa-arrow-right"></i></li>
|
||||
<li class="features-flow__item features-flow__item--live">
|
||||
<span class="features-flow__icon features-flow__icon--live" aria-hidden="true">
|
||||
<span class="features-flow__dot"></span>
|
||||
</span>
|
||||
<span class="features-flow__label"><%= t("pages.features.flow_live") %></span>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
<div class="features-hero__visual">
|
||||
<%= image_tag "/hero-devices.png",
|
||||
alt: t("pages.features.hero_alt"),
|
||||
class: "features-hero__img",
|
||||
loading: "eager",
|
||||
fetchpriority: "high" %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section wrap features-how" aria-labelledby="features-how-title">
|
||||
<h2 id="features-how-title"><%= t("pages.features.how_title") %></h2>
|
||||
<ol class="features-how__steps">
|
||||
<li class="features-how__step">
|
||||
<span class="features-how__num" aria-hidden="true">1</span>
|
||||
<h3><%= t("pages.features.how_1_title") %></h3>
|
||||
<p><%= t("pages.features.how_1_body") %></p>
|
||||
</li>
|
||||
<li class="features-how__step">
|
||||
<span class="features-how__num" aria-hidden="true">2</span>
|
||||
<h3><%= t("pages.features.how_2_title") %></h3>
|
||||
<p><%= t("pages.features.how_2_body") %></p>
|
||||
</li>
|
||||
<li class="features-how__step">
|
||||
<span class="features-how__num" aria-hidden="true">3</span>
|
||||
<h3><%= t("pages.features.how_3_title") %></h3>
|
||||
<p><%= t("pages.features.how_3_body") %></p>
|
||||
</li>
|
||||
<li class="features-how__step">
|
||||
<span class="features-how__num" aria-hidden="true">4</span>
|
||||
<h3><%= t("pages.features.how_4_title") %></h3>
|
||||
<p><%= t("pages.features.how_4_body") %></p>
|
||||
</li>
|
||||
<li class="features-how__step">
|
||||
<span class="features-how__num" aria-hidden="true">5</span>
|
||||
<h3><%= t("pages.features.how_5_title") %></h3>
|
||||
<p><%= t("pages.features.how_5_body") %></p>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="section wrap features-accounts" aria-labelledby="features-accounts-title">
|
||||
<div class="features-panel">
|
||||
<h2 id="features-accounts-title"><%= t("pages.features.accounts_title") %></h2>
|
||||
<p class="features-panel__lead"><%= t("pages.features.accounts_lead") %></p>
|
||||
<div class="features-accounts__grid">
|
||||
<div class="features-accounts__card">
|
||||
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-user-plus"></i></span>
|
||||
<h3><%= t("pages.features.accounts_invite_title") %></h3>
|
||||
<p><%= t("pages.features.accounts_invite_body") %></p>
|
||||
</div>
|
||||
<div class="features-accounts__card">
|
||||
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||
<h3><%= t("pages.features.accounts_transmit_title") %></h3>
|
||||
<p><%= t("pages.features.accounts_transmit_body") %></p>
|
||||
</div>
|
||||
<div class="features-accounts__card">
|
||||
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-user-shield"></i></span>
|
||||
<h3><%= t("pages.features.accounts_revoke_title") %></h3>
|
||||
<p><%= t("pages.features.accounts_revoke_body") %></p>
|
||||
</div>
|
||||
</div>
|
||||
<p class="features-highlight"><%= t("pages.features.accounts_highlight") %></p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap features-score" aria-labelledby="features-score-title">
|
||||
<div class="features-split">
|
||||
<div class="features-split__copy">
|
||||
<h2 id="features-score-title"><%= t("pages.features.score_title") %></h2>
|
||||
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.score_lead") %></p>
|
||||
<ul class="features-checklist">
|
||||
<li><%= t("pages.features.score_item_app") %></li>
|
||||
<li><%= t("pages.features.score_item_signup") %></li>
|
||||
<li><%= t("pages.features.score_item_password") %></li>
|
||||
<li><%= t("pages.features.score_item_link") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="features-score__card" aria-label="<%= t("pages.features.score_card_aria") %>">
|
||||
<div class="features-score__visual" aria-hidden="true">
|
||||
<div class="features-score__phone">
|
||||
<div class="features-score__phone-top">
|
||||
<span class="features-score__live"><span class="features-score__live-dot"></span> REGIA</span>
|
||||
<span class="features-score__set">3° set</span>
|
||||
</div>
|
||||
<div class="features-score__board">
|
||||
<div class="features-score__team">
|
||||
<span class="features-score__team-name">Casa</span>
|
||||
<span class="features-score__points">21</span>
|
||||
</div>
|
||||
<span class="features-score__sep">–</span>
|
||||
<div class="features-score__team features-score__team--away">
|
||||
<span class="features-score__team-name">Ospiti</span>
|
||||
<span class="features-score__points">18</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="features-score__actions">
|
||||
<span class="features-score__btn">+1</span>
|
||||
<span class="features-score__btn features-score__btn--muted">+1</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="features-score__linkchip">
|
||||
<i class="fa-solid fa-link" aria-hidden="true"></i>
|
||||
<span>matchlivetv.it/regia/…</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="features-score__body">
|
||||
<span class="feature-card__badge feature-card__badge--link"><%= t("pages.features.access_link") %></span>
|
||||
<p class="features-score__card-title"><%= t("pages.features.score_card_title") %></p>
|
||||
<p><%= t("pages.features.score_card_body") %></p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap features-distribute" aria-labelledby="features-distribute-title">
|
||||
<h2 id="features-distribute-title"><%= t("pages.features.distribute_title") %></h2>
|
||||
<p class="features-panel__lead"><%= t("pages.features.distribute_lead") %></p>
|
||||
<ol class="features-pipeline" aria-label="<%= t("pages.features.distribute_aria") %>">
|
||||
<li class="features-pipeline__node">
|
||||
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
|
||||
<span class="features-pipeline__label"><%= t("pages.features.distribute_phone") %></span>
|
||||
</li>
|
||||
<li class="features-pipeline__join" aria-hidden="true"></li>
|
||||
<li class="features-pipeline__node features-pipeline__node--hub">
|
||||
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<span class="features-pipeline__label"><%= t("pages.features.distribute_mltv") %></span>
|
||||
</li>
|
||||
<li class="features-pipeline__join" aria-hidden="true"></li>
|
||||
<li class="features-pipeline__targets">
|
||||
<div class="features-pipeline__node">
|
||||
<span class="features-pipeline__icon features-pipeline__icon--blue" aria-hidden="true"><i class="fa-solid fa-users"></i></span>
|
||||
<span class="features-pipeline__label"><%= t("pages.features.distribute_viewers") %></span>
|
||||
</div>
|
||||
<div class="features-pipeline__node">
|
||||
<span class="features-pipeline__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
|
||||
<span class="features-pipeline__label"><%= t("pages.features.distribute_youtube") %></span>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
<section class="section wrap features-youtube" aria-labelledby="features-youtube-title">
|
||||
<div class="features-panel features-panel--accent features-youtube__panel">
|
||||
<div class="features-youtube__copy">
|
||||
<span class="feature-card__badge feature-card__badge--invite"><%= t("pages.features.youtube_badge") %></span>
|
||||
<h2 id="features-youtube-title"><%= t("pages.features.youtube_title") %></h2>
|
||||
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.youtube_body") %></p>
|
||||
</div>
|
||||
<div class="features-youtube__visual" aria-hidden="true">
|
||||
<div class="features-yt-mock">
|
||||
<div class="features-yt-mock__player">
|
||||
<span class="features-yt-mock__live"><span class="features-yt-mock__dot"></span> LIVE</span>
|
||||
<span class="features-yt-mock__play"><i class="fa-brands fa-youtube"></i></span>
|
||||
</div>
|
||||
<div class="features-yt-mock__meta">
|
||||
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
|
||||
<div class="features-yt-mock__text">
|
||||
<strong><%= t("pages.features.youtube_mock_channel") %></strong>
|
||||
<span><%= t("pages.features.youtube_mock_subs") %></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.staff_access_title") %></h3>
|
||||
<p><%= t("pages.features.staff_access_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.staff_plans_title") %></h3>
|
||||
<p><%= t("pages.features.staff_plans_body") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2><%= t("pages.features.live_section_title") %></h2>
|
||||
<div class="features-grid">
|
||||
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
||||
<div class="features-split features-split--reverse">
|
||||
<div class="features-split__copy">
|
||||
<h2 id="features-replay-title"><%= t("pages.features.replay_title") %></h2>
|
||||
<p class="features-panel__lead features-panel__lead--left"><%= t("pages.features.replay_lead") %></p>
|
||||
<ul class="features-checklist">
|
||||
<li><%= t("pages.features.replay_item_server") %></li>
|
||||
<li><%= t("pages.features.replay_item_plan") %></li>
|
||||
<li><%= t("pages.features.replay_item_download") %></li>
|
||||
</ul>
|
||||
</div>
|
||||
<aside class="features-replay__card" aria-label="<%= t("pages.features.replay_card_title") %>">
|
||||
<div class="features-replay__visual" aria-hidden="true">
|
||||
<div class="features-archive-mock">
|
||||
<div class="features-archive-mock__head">
|
||||
<i class="fa-solid fa-cloud" aria-hidden="true"></i>
|
||||
<span><%= t("pages.features.replay_card_title") %></span>
|
||||
</div>
|
||||
<ul class="features-archive-mock__list">
|
||||
<li>
|
||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
|
||||
<span class="features-archive-mock__info">
|
||||
<strong><%= t("pages.features.replay_mock_1_title") %></strong>
|
||||
<em><%= t("pages.features.replay_mock_1_meta") %></em>
|
||||
</span>
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||
</li>
|
||||
<li>
|
||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
|
||||
<span class="features-archive-mock__info">
|
||||
<strong><%= t("pages.features.replay_mock_2_title") %></strong>
|
||||
<em><%= t("pages.features.replay_mock_2_meta") %></em>
|
||||
</span>
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||
</li>
|
||||
<li>
|
||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
|
||||
<span class="features-archive-mock__info">
|
||||
<strong><%= t("pages.features.replay_mock_3_title") %></strong>
|
||||
<em><%= t("pages.features.replay_mock_3_meta") %></em>
|
||||
</span>
|
||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="features-replay__body">
|
||||
<p class="features-score__card-title"><%= t("pages.features.replay_card_title") %></p>
|
||||
<p><%= t("pages.features.replay_card_body") %></p>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap features-more" aria-labelledby="features-more-title">
|
||||
<h2 id="features-more-title"><%= t("pages.features.more_title") %></h2>
|
||||
<div class="features-grid features-grid--compact">
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_phone_title") %></h3>
|
||||
<p><%= t("pages.features.live_phone_body") %></p>
|
||||
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
<h3><%= t("pages.features.more_stable_title") %></h3>
|
||||
<p><%= t("pages.features.more_stable_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_score_title") %></h3>
|
||||
<p><%= t("pages.features.live_score_body") %></p>
|
||||
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span>
|
||||
<h3><%= t("pages.features.more_link_title") %></h3>
|
||||
<p><%= t("pages.features.more_link_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_link_title") %></h3>
|
||||
<p><%= t("pages.features.live_link_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_archive_title") %></h3>
|
||||
<p><%= t("pages.features.live_archive_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_youtube_mltv_title") %></h3>
|
||||
<p><%= t("pages.features.live_youtube_mltv_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_youtube_club_title") %></h3>
|
||||
<p><%= t("pages.features.live_youtube_club_body") %></p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<h3><%= t("pages.features.live_website_title") %></h3>
|
||||
<p><%= t("pages.features.live_website_body") %></p>
|
||||
<span class="feature-card__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
|
||||
<h3><%= t("pages.features.more_yt_mltv_title") %></h3>
|
||||
<p><%= t("pages.features.more_yt_mltv_body") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p style="text-align:center;margin:40px 0">
|
||||
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||
</p>
|
||||
</div>
|
||||
<section class="section wrap features-cta" aria-labelledby="features-cta-title">
|
||||
<div class="features-cta__box">
|
||||
<h2 id="features-cta-title"><%= t("pages.features.cta_title") %></h2>
|
||||
<p><%= t("pages.features.cta_body") %></p>
|
||||
<div class="features-cta__actions">
|
||||
<%= link_to t("pages.features.cta_primary"), public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to t("pages.features.cta_secondary"), public_prezzi_path, class: "btn btn-outline" %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"url" => seo_absolute_url(root_path),
|
||||
"applicationCategory" => "SportsApplication",
|
||||
"operatingSystem" => "Web, Android, iOS",
|
||||
"description" => t("home.schema_description")
|
||||
"description" => t("home.schema_description"),
|
||||
"sameAs" => [MatchLiveTv.app_store_url, MatchLiveTv.play_store_url]
|
||||
}.to_json) %>
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -26,6 +27,10 @@
|
||||
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||
<%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<div class="hero-stores">
|
||||
<p class="hero-stores-label"><%= t("home.stores_label") %></p>
|
||||
<%= render "shared/store_badges", variant: "hero" %>
|
||||
</div>
|
||||
<ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
|
||||
<li class="hero-feature-item">
|
||||
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
|
||||
|
||||
@@ -22,24 +22,67 @@
|
||||
|
||||
<%= render "shared/plan_cards" %>
|
||||
|
||||
<div class="table-scroll compare-table-wrap">
|
||||
<table class="compare-table">
|
||||
<colgroup>
|
||||
<col class="compare-table__feature">
|
||||
<col class="compare-table__plan">
|
||||
<col class="compare-table__plan">
|
||||
<col class="compare-table__plan compare-table__plan--featured">
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Free</th>
|
||||
<th>Premium Light</th>
|
||||
<th>Premium Full</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td><%= t("pages.pricing.table_staff") %></td><td>1</td><td>5</td><td><%= t("pages.pricing.table_unlimited") %></td></tr>
|
||||
<tr>
|
||||
<td><%= t("pages.pricing.table_staff") %></td>
|
||||
<td>1</td>
|
||||
<td>5</td>
|
||||
<td><%= t("pages.pricing.table_unlimited") %></td>
|
||||
</tr>
|
||||
<tr><td><%= t("pages.pricing.table_matches") %></td><td>1</td><td>3</td><td>10</td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_live_mltv") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_youtube") %></td><td><%= t("pages.pricing.table_no") %></td><td>Match Live TV</td><td><%= t("pages.pricing.table_youtube_club") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_replay") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.plans.replay_days", count: 30) %></td><td><%= t("pages.plans.replay_days", count: 90) %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_download") %></td><td><%= t("pages.pricing.table_no") %></td><td><%= t("pages.pricing.table_yes") %></td><td><%= t("pages.pricing.table_yes") %></td></tr>
|
||||
<tr><td><%= t("pages.pricing.table_price") %></td><td><%= t("pages.pricing.table_price_free") %></td><td><%= raw t("pages.pricing.table_price_light_html") %></td><td><%= raw t("pages.pricing.table_price_full_html") %></td></tr>
|
||||
<tr>
|
||||
<td><%= t("pages.pricing.table_price") %></td>
|
||||
<td><%= t("pages.pricing.table_price_free") %></td>
|
||||
<td>
|
||||
<div class="compare-price">
|
||||
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "yearly") %></strong>
|
||||
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
|
||||
<small class="compare-price__note"><%= t(
|
||||
"pages.pricing.table_price_note",
|
||||
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_light", interval: "yearly"),
|
||||
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_light", interval: "monthly")
|
||||
) %></small>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div class="compare-price">
|
||||
<strong><%= Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "yearly") %></strong>
|
||||
<span class="compare-price__launch"><%= t("pages.plans.launch_price") %></span>
|
||||
<small class="compare-price__note"><%= t(
|
||||
"pages.pricing.table_price_note",
|
||||
list: Billing::Stripe::PriceCatalog.list_label(plan_slug: "premium_full", interval: "yearly"),
|
||||
monthly: Billing::Stripe::PriceCatalog.label(plan_slug: "premium_full", interval: "monthly")
|
||||
) %></small>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<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@matchlive.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -77,6 +77,7 @@
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.privacy.s5_title") %></h2>
|
||||
<div class="table-scroll">
|
||||
<table class="legal-table">
|
||||
<thead>
|
||||
<tr><th><%= t("legal.privacy.s5_col_purpose") %></th><th><%= t("legal.privacy.s5_col_basis") %></th></tr>
|
||||
@@ -108,6 +109,7 @@
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<% content_for :title, t("legal.support.title") %>
|
||||
<% content_for :meta_description, t("legal.support.meta_description") %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_support_path) %>
|
||||
|
||||
<div class="wrap legal-doc">
|
||||
<h1><%= t("legal.support.h1") %></h1>
|
||||
<p><%= t("legal.support.intro") %></p>
|
||||
|
||||
<p>
|
||||
<%= t("legal.support.email_label") %>
|
||||
<a href="mailto:<%= MatchLiveTv.support_email %>"><%= MatchLiveTv.support_email %></a>
|
||||
</p>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.access_title") %></h2>
|
||||
<p><%= t("legal.support.access_body") %></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.live_title") %></h2>
|
||||
<p><%= t("legal.support.live_body") %></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.team_title") %></h2>
|
||||
<p><%= t("legal.support.team_body") %></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2><%= t("legal.support.privacy_title") %></h2>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"legal.support.privacy_body_html",
|
||||
privacy_link: link_to(t("legal.support.privacy_link_text"), public_privacy_path)
|
||||
) %>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
@@ -4,6 +4,7 @@
|
||||
<section class="auth-page">
|
||||
<h1><%= t("auth.password_reset.title") %></h1>
|
||||
<div class="card">
|
||||
<p class="muted" style="font-size:0.9rem"><%= t("password_policy.hint") %></p>
|
||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||
<%= hidden_field_tag :token, @token %>
|
||||
<%= render "shared/input_toggle",
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
required: true,
|
||||
minlength: 8,
|
||||
autocomplete: "new-password" %>
|
||||
<p class="muted" style="font-size:0.9rem;margin-top:-0.5rem"><%= t("password_policy.hint") %></p>
|
||||
<%= render "shared/input_toggle",
|
||||
name: "user[password_confirmation]",
|
||||
id: "user_password_confirmation",
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-top:24px;text-align:center">
|
||||
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlive.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
||||
<p style="color:#aaa"><%= t("team.billing.contact_lead") %> <%= mail_to "info@matchlivetv.it", t("team.billing.contact_cta") %> <%= t("team.billing.contact_trailer") %></p>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:20px"><%= link_to t("team.billing.club_payments_link"), public_club_billing_path(@team.club) %></p>
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem"><%= t("billing.documents.table_heading") %></h3>
|
||||
<% if payments.any? %>
|
||||
<div class="table-scroll">
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -60,6 +61,7 @@
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p style="color:#888"><%= t("billing.documents.no_payments") %></p>
|
||||
<% end %>
|
||||
|
||||
@@ -58,10 +58,18 @@
|
||||
}
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setOpen(menu.hidden);
|
||||
});
|
||||
|
||||
// Keep parent mobile nav open while interacting with the switcher UI.
|
||||
root.addEventListener("click", function (e) {
|
||||
if (e.target.closest(".lang-switcher__toggle")) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("click", function (e) {
|
||||
if (!root.contains(e.target)) setOpen(false);
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
<%= render "shared/store_badges", variant: "footer" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= 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 %> ·
|
||||
<%= link_to t("common.faq"), public_faq_path %> ·
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<%# Footer minimale per App Store Review: solo link legali/supporto, nessun CTA commerciale. %>
|
||||
<footer class="site-footer">
|
||||
<div class="wrap">
|
||||
<div>
|
||||
<strong style="color:#fff">Match Live TV</strong> — <%= t("footer.tagline") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.privacy"), public_privacy_path %> ·
|
||||
<%= link_to t("common.cookies"), public_cookies_path %> ·
|
||||
<%= link_to t("common.terms"), public_termini_path %>
|
||||
· <button type="button" class="footer-link-btn" data-cookie-manage><%= t("footer.manage_cookies") %></button>
|
||||
</div>
|
||||
<div class="site-footer__legal">
|
||||
<p><%= t("footer.copyright") %></p>
|
||||
<p><%= t("footer.responsibility") %></p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
@@ -19,6 +19,15 @@
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<div class="nav-mobile-head">
|
||||
<%= link_to root_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||
<span class="brand">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to t("nav.home"), root_path, class: (request.path == "/" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.features"), public_features_path, class: (request.path == "/funzionalita" ? "nav-active" : nil) %>
|
||||
<%= link_to t("nav.pricing"), public_prezzi_path, class: (request.path == "/prezzi" ? "nav-active" : nil) %>
|
||||
@@ -34,14 +43,12 @@
|
||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
|
||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||
<% else %>
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
<%= link_to t("nav.signup"), public_signup_path, class: "btn btn-primary nav-btn" %>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -77,8 +84,16 @@
|
||||
|
||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
function shouldCloseNavOnControl(el) {
|
||||
// Language toggle must keep the mobile menu open; only a locale choice may close it.
|
||||
if (!el.closest("[data-lang-switcher]")) return true;
|
||||
return el.classList.contains("lang-switcher__option");
|
||||
}
|
||||
|
||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||
el.addEventListener("click", closeMenu);
|
||||
el.addEventListener("click", function () {
|
||||
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<%# Chrome minimale per App Store Review: branding, lingua, Privacy e Supporto — senza CTA commerciali. %>
|
||||
<div class="site-chrome">
|
||||
<div class="site-masthead">
|
||||
<div class="wrap mast-inner">
|
||||
<%= link_to public_support_path, class: "mast-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="mast-brand-logo" src="/logo.png?v=3" alt="Match Live TV" width="40" height="40" decoding="async">
|
||||
<span class="brand" aria-hidden="true">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
|
||||
<div class="mast-tools">
|
||||
<button type="button" class="nav-toggle" aria-label="<%= t('nav.open_menu') %>" aria-expanded="false" aria-controls="site-nav">
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
<span class="nav-toggle-bar" aria-hidden="true"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav id="site-nav" class="nav" aria-label="<%= t('nav.main_menu') %>" aria-hidden="true">
|
||||
<div class="nav-panel">
|
||||
<div class="nav-mobile-head">
|
||||
<%= link_to public_support_path, class: "nav-mobile-brand", aria: { label: "Match Live TV" }, title: "Match Live TV" do %>
|
||||
<img class="nav-mobile-brand-logo" src="/logo.png?v=3" alt="" width="40" height="40" decoding="async">
|
||||
<span class="brand">Match <span>Live TV</span></span>
|
||||
<% end %>
|
||||
<div class="nav-lang">
|
||||
<%= render "shared/language_switcher" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= link_to t("common.support"), public_support_path, class: "nav-active" %>
|
||||
<%= link_to t("common.privacy"), public_privacy_path, class: (request.path == "/privacy" ? "nav-active" : nil) %>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="nav-backdrop" id="nav-backdrop" aria-hidden="true"></div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
var chrome = document.querySelector(".site-chrome");
|
||||
var toggle = document.querySelector(".nav-toggle");
|
||||
var backdrop = document.getElementById("nav-backdrop");
|
||||
var nav = document.getElementById("site-nav");
|
||||
if (!chrome || !toggle || !nav) return;
|
||||
var openLabel = <%= raw t("nav.open_menu").to_json %>;
|
||||
var closeLabel = <%= raw t("nav.close_menu").to_json %>;
|
||||
|
||||
function setOpen(open) {
|
||||
chrome.classList.toggle("nav-open", open);
|
||||
document.body.classList.toggle("nav-menu-open", open);
|
||||
toggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||
toggle.setAttribute("aria-label", open ? closeLabel : openLabel);
|
||||
nav.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
if (backdrop) backdrop.setAttribute("aria-hidden", open ? "false" : "true");
|
||||
}
|
||||
|
||||
function closeMenu() { setOpen(false); }
|
||||
|
||||
toggle.addEventListener("click", function (e) {
|
||||
e.stopPropagation();
|
||||
document.body.classList.contains("nav-menu-open") ? closeMenu() : setOpen(true);
|
||||
});
|
||||
|
||||
if (backdrop) backdrop.addEventListener("click", closeMenu);
|
||||
|
||||
function shouldCloseNavOnControl(el) {
|
||||
if (!el.closest("[data-lang-switcher]")) return true;
|
||||
return el.classList.contains("lang-switcher__option");
|
||||
}
|
||||
|
||||
nav.querySelectorAll("a, button").forEach(function (el) {
|
||||
el.addEventListener("click", function () {
|
||||
if (shouldCloseNavOnControl(el)) closeMenu();
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("resize", function () {
|
||||
if (window.matchMedia("(min-width: 900px)").matches) closeMenu();
|
||||
});
|
||||
|
||||
document.addEventListener("keydown", function (e) {
|
||||
if (e.key === "Escape") closeMenu();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -15,23 +15,33 @@
|
||||
current_interval: current_interval,
|
||||
subscription: subscription
|
||||
) : nil %>
|
||||
<div class="plan-card">
|
||||
<% featured = plan.slug == "premium_full" %>
|
||||
<div class="plan-card<%= " plan-card--featured" if featured %>">
|
||||
<% if featured %>
|
||||
<p class="plan-card__recommend"><%= t("pages.plans.recommended_badge") %></p>
|
||||
<% end %>
|
||||
<%= render "shared/plan_title", plan: plan %>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div>
|
||||
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: light_monthly) %></p>
|
||||
<% else %>
|
||||
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||
<div class="plan-price"><%= full_yearly %></div>
|
||||
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: full_monthly) %></p>
|
||||
<% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
|
||||
<% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
|
||||
<% list = Billing::Stripe::PriceCatalog.list_label(plan_slug: plan.slug, interval: "yearly") %>
|
||||
<% equiv = Billing::Stripe::PriceCatalog.equivalent_monthly_label(plan_slug: plan.slug) %>
|
||||
<div class="plan-price-block">
|
||||
<% if list.present? %>
|
||||
<div class="plan-price-list"><s><%= list %></s></div>
|
||||
<% end %>
|
||||
<div class="plan-price<%= " plan-price--featured" if featured %>"><%= yearly %></div>
|
||||
<span class="plan-price-badge"><%= t("pages.plans.launch_price") %></span>
|
||||
<% if equiv.present? %>
|
||||
<p class="plan-price-equiv"><%= t("pages.plans.price_equiv", price: equiv) %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: monthly) %></p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<li><%= raw t("pages.plans.staff_html", count: plan.max_staff_transmission || t("pages.plans.staff_unlimited")) %></li>
|
||||
<li><%= raw(plan.max_staff_transmission ? t("pages.plans.staff_html", count: plan.max_staff_transmission) : t("pages.plans.staff_unlimited_html")) %></li>
|
||||
<li><%= raw t("pages.plans.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
|
||||
<li><%= raw t(
|
||||
"pages.plans.replay_html",
|
||||
@@ -48,6 +58,9 @@
|
||||
end
|
||||
) %></li>
|
||||
</ul>
|
||||
<% if plan.slug == "premium_full" %>
|
||||
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
|
||||
<% end %>
|
||||
<% if billing_mode %>
|
||||
<% action_kind = action[:kind] %>
|
||||
<% if action_kind == :current %>
|
||||
@@ -78,8 +91,12 @@
|
||||
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
|
||||
<% else %>
|
||||
<div class="plan-interval-actions">
|
||||
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %>
|
||||
<%= link_to t("pages.plans.register_with_price", price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)),
|
||||
<% %w[yearly monthly].each do |interval| %>
|
||||
<%= link_to t(
|
||||
"billing.messages.activate_button",
|
||||
plan: plan_cta_name(plan),
|
||||
price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||
),
|
||||
public_signup_path(plan: plan.slug, interval: interval),
|
||||
class: plan_interval_button_class(interval) %>
|
||||
<% end %>
|
||||
@@ -88,4 +105,3 @@
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<% variant = local_assigns.fetch(:variant, "hero") %>
|
||||
<div class="store-badges store-badges--<%= variant %>">
|
||||
<%= link_to MatchLiveTv.app_store_url,
|
||||
class: "store-badge",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
aria: { label: t("home.store_app_store_aria") } do %>
|
||||
<%= image_tag "/images/store/apple.svg",
|
||||
alt: "",
|
||||
class: "store-badge__logo store-badge__logo--apple",
|
||||
aria: { hidden: true } %>
|
||||
<span>
|
||||
<span class="store-badge__kicker"><%= t("home.store_app_store_kicker") %></span>
|
||||
<span class="store-badge__name"><%= t("home.store_app_store_name") %></span>
|
||||
</span>
|
||||
<% end %>
|
||||
<%= link_to MatchLiveTv.play_store_url,
|
||||
class: "store-badge",
|
||||
target: "_blank",
|
||||
rel: "noopener noreferrer",
|
||||
aria: { label: t("home.store_play_aria") } do %>
|
||||
<%= image_tag "/images/store/google-play.svg",
|
||||
alt: "",
|
||||
class: "store-badge__logo store-badge__logo--play",
|
||||
aria: { hidden: true } %>
|
||||
<span>
|
||||
<span class="store-badge__kicker"><%= t("home.store_play_kicker") %></span>
|
||||
<span class="store-badge__name"><%= t("home.store_play_name") %></span>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -78,7 +78,13 @@ module MatchLiveTv
|
||||
end
|
||||
|
||||
def privacy_controller_email
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlive.it")
|
||||
ENV.fetch("PRIVACY_CONTACT_EMAIL", "privacy@matchlivetv.it")
|
||||
end
|
||||
|
||||
# Email di supporto (App Store Support URL e contatti assistenza).
|
||||
# Preferisce SUPPORT_CONTACT_EMAIL; default = contatto commerciale già usato nel sito.
|
||||
def support_email
|
||||
ENV["SUPPORT_CONTACT_EMAIL"].presence || "info@matchlivetv.it"
|
||||
end
|
||||
|
||||
def privacy_controller_address
|
||||
@@ -174,5 +180,13 @@ module MatchLiveTv
|
||||
def google_analytics_configured?
|
||||
google_analytics_measurement_id.present?
|
||||
end
|
||||
|
||||
def app_store_url
|
||||
"https://apps.apple.com/us/app/matchlivetv/id6799510705"
|
||||
end
|
||||
|
||||
def play_store_url
|
||||
"https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,6 +18,9 @@ de:
|
||||
logout_success: Abgemeldet
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das neue Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_long: Das neue Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das neue Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert
|
||||
ops_acknowledged: Vorfall übernommen
|
||||
@@ -53,7 +56,7 @@ de:
|
||||
edit:
|
||||
title: Passwort ändern
|
||||
current_password_label: Aktuelles Passwort
|
||||
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
confirm_password_label: Neues Passwort bestätigen
|
||||
submit: Passwort speichern
|
||||
cancel: Abbrechen
|
||||
|
||||
@@ -18,6 +18,9 @@ en:
|
||||
logout_success: Signed out
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: The new password must be at least 8 characters long
|
||||
password_too_long: New password cannot exceed 72 characters
|
||||
password_too_weak: "New password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated
|
||||
ops_acknowledged: Incident acknowledged
|
||||
@@ -53,7 +56,7 @@ en:
|
||||
edit:
|
||||
title: Change password
|
||||
current_password_label: Current password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
confirm_password_label: Confirm new password
|
||||
submit: Save password
|
||||
cancel: Cancel
|
||||
|
||||
@@ -18,6 +18,9 @@ es:
|
||||
logout_success: Sesión cerrada
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La nueva contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La nueva contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La nueva contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
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
|
||||
ops_acknowledged: Incidencia asumida
|
||||
@@ -53,7 +56,7 @@ es:
|
||||
edit:
|
||||
title: Cambiar contraseña
|
||||
current_password_label: Contraseña actual
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
confirm_password_label: Confirma la nueva contraseña
|
||||
submit: Guardar contraseña
|
||||
cancel: Cancelar
|
||||
|
||||
@@ -18,6 +18,9 @@ fr:
|
||||
logout_success: Déconnecté
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le nouveau mot de passe doit contenir au moins 8 caractères
|
||||
password_too_long: Le nouveau mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le nouveau mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
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
|
||||
ops_acknowledged: Incident pris en charge
|
||||
@@ -53,7 +56,7 @@ fr:
|
||||
edit:
|
||||
title: Changer le mot de passe
|
||||
current_password_label: Mot de passe actuel
|
||||
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
confirm_password_label: Confirmer le nouveau mot de passe
|
||||
submit: Enregistrer le mot de passe
|
||||
cancel: Annuler
|
||||
|
||||
@@ -18,6 +18,9 @@ it:
|
||||
logout_success: Disconnesso
|
||||
password_current_incorrect: Password attuale non corretta
|
||||
password_too_short: La nuova password deve avere almeno 8 caratteri
|
||||
password_too_long: La nuova password non può superare i 72 caratteri
|
||||
password_too_weak: "La nuova password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata
|
||||
ops_acknowledged: Incidente preso in carico
|
||||
@@ -53,7 +56,7 @@ it:
|
||||
edit:
|
||||
title: Cambia password
|
||||
current_password_label: Password attuale
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
confirm_password_label: Conferma nuova password
|
||||
submit: Salva password
|
||||
cancel: Annulla
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
de:
|
||||
password_policy:
|
||||
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Passwort
|
||||
admin_account:
|
||||
password: Passwort
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
club:
|
||||
back_to_club: "← Verein"
|
||||
sport_label: Hauptsportart
|
||||
@@ -60,12 +82,12 @@ de:
|
||||
first_team_name_label: Teamname
|
||||
first_team_name_placeholder: "z. B. Under 15"
|
||||
plan_label: "Starttarif (für den gesamten Verein)"
|
||||
plan_free: "Free — 1 Übertragungsverantwortlicher pro Team, 1 Livestream"
|
||||
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 — 40 €/Jahr (Light) oder 200 €/Jahr (Full)"
|
||||
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)"
|
||||
interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
|
||||
interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
|
||||
submit: Verein erstellen
|
||||
billing:
|
||||
title: "Abonnement — %{name}"
|
||||
@@ -401,6 +423,9 @@ de:
|
||||
upgrade_info_line: "Upgrade (höherer Tarif oder Wechsel zur jährlichen Abrechnung): Die Differenz wird sofort von der gespeicherten Stripe-Karte abgebucht."
|
||||
downgrade_info_line: "Downgrade zwischen kostenpflichtigen Tarifen (z. B. Full → Light): Der neue Tarif gilt ab der nächsten Verlängerung; bis dahin bleiben die Funktionen des aktuellen Tarifs ohne zusätzliche Kosten erhalten."
|
||||
already_requested_line: "Du hast %{plan} bereits ab dem %{when} angefordert: Bis dahin bleibt %{current_plan} aktiv."
|
||||
prices:
|
||||
yearly: "%{amount}/Jahr"
|
||||
monthly: "%{amount}/Monat"
|
||||
regia:
|
||||
meta_title_fallback: "Regie — Match Live TV"
|
||||
title: "Regie — %{home} vs %{away}"
|
||||
@@ -600,12 +625,29 @@ de:
|
||||
welcome_back: Willkommen zurück!
|
||||
invalid_credentials: E-Mail oder Passwort ungültig
|
||||
logged_out: Abgemeldet
|
||||
unauthorized: Nicht autorisiert
|
||||
invalid_token: Ungültiges Token
|
||||
password_resets:
|
||||
email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts.
|
||||
invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an.
|
||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
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.
|
||||
accounts:
|
||||
name_required: Der Name ist erforderlich
|
||||
profile_updated: Profil aktualisiert.
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
|
||||
password_too_long: Das Passwort darf höchstens 72 Zeichen lang sein
|
||||
password_too_weak: Das Passwort muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert.
|
||||
password_update_failed: Passwort konnte nicht aktualisiert werden
|
||||
replay:
|
||||
download_unavailable: Download nicht verfügbar
|
||||
not_available: Replay nicht verfügbar
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
en:
|
||||
password_policy:
|
||||
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Main sport
|
||||
@@ -60,12 +77,12 @@ en:
|
||||
first_team_name_label: Team name
|
||||
first_team_name_placeholder: "e.g. Under 15"
|
||||
plan_label: "Starting plan (for the whole club)"
|
||||
plan_free: "Free — 1 broadcast manager per team, 1 live stream"
|
||||
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 — €40/year (Light) or €200/year (Full)"
|
||||
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)"
|
||||
interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
|
||||
interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
|
||||
submit: Create club
|
||||
billing:
|
||||
title: "Subscription — %{name}"
|
||||
@@ -401,6 +418,9 @@ en:
|
||||
upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card."
|
||||
downgrade_info_line: "Downgrade between paid plans (e.g. Full → Light): the new plan takes effect from the next renewal; until then the current plan's features remain, with no additional charge now."
|
||||
already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active."
|
||||
prices:
|
||||
yearly: "%{amount}/year"
|
||||
monthly: "%{amount}/month"
|
||||
regia:
|
||||
meta_title_fallback: "Control room — Match Live TV"
|
||||
title: "Control room — %{home} vs %{away}"
|
||||
@@ -600,12 +620,29 @@ en:
|
||||
welcome_back: Welcome back!
|
||||
invalid_credentials: Invalid email or password
|
||||
logged_out: Logged out
|
||||
unauthorized: Unauthorized
|
||||
invalid_token: Invalid token
|
||||
password_resets:
|
||||
email_sent: If the email is registered, you'll receive a password reset link shortly.
|
||||
invalid_or_expired_link: Invalid or expired link. Request a new password reset.
|
||||
password_min_length: Password must be at least 8 characters
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_too_long: Password cannot exceed 72 characters
|
||||
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
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.
|
||||
accounts:
|
||||
name_required: Name is required
|
||||
profile_updated: Profile updated.
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_too_long: Password cannot exceed 72 characters
|
||||
password_too_weak: "Password must include at least 3 of: lowercase, uppercase, numbers and symbols"
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated.
|
||||
password_update_failed: Unable to update password
|
||||
replay:
|
||||
download_unavailable: Download not available
|
||||
not_available: Replay not available
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
es:
|
||||
password_policy:
|
||||
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Contraseña
|
||||
admin_account:
|
||||
password: Contraseña
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Deporte principal
|
||||
@@ -60,12 +82,12 @@ es:
|
||||
first_team_name_label: Nombre del equipo
|
||||
first_team_name_placeholder: "ej. Sub-15"
|
||||
plan_label: "Plan inicial (para todo el club)"
|
||||
plan_free: "Free — 1 responsable de transmisión por equipo, 1 directo"
|
||||
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 — 40 €/año (Light) o 200 €/año (Full)"
|
||||
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)"
|
||||
interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
|
||||
interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
|
||||
submit: Crear club
|
||||
billing:
|
||||
title: "Suscripción — %{name}"
|
||||
@@ -401,6 +423,9 @@ es:
|
||||
upgrade_info_line: "Mejora (plan superior o cambio a facturación anual): la diferencia se cobra de inmediato en la tarjeta guardada en Stripe."
|
||||
downgrade_info_line: "Downgrade entre planes de pago (p. ej. Full → Light): el nuevo plan se activa desde la próxima renovación; hasta entonces se mantienen las funciones del plan actual, sin cargo adicional ahora."
|
||||
already_requested_line: "Ya has solicitado %{plan} desde el %{when}: hasta entonces sigue activo %{current_plan}."
|
||||
prices:
|
||||
yearly: "%{amount}/año"
|
||||
monthly: "%{amount}/mes"
|
||||
regia:
|
||||
meta_title_fallback: "Control — Match Live TV"
|
||||
title: "Control — %{home} vs %{away}"
|
||||
@@ -600,12 +625,29 @@ es:
|
||||
welcome_back: "¡Bienvenido de nuevo!"
|
||||
invalid_credentials: Correo o contraseña no válidos
|
||||
logged_out: Sesión cerrada
|
||||
unauthorized: No autorizado
|
||||
invalid_token: Token no válido
|
||||
password_resets:
|
||||
email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña.
|
||||
invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña.
|
||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
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.
|
||||
accounts:
|
||||
name_required: El nombre es obligatorio
|
||||
profile_updated: Perfil actualizado.
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_too_long: La contraseña no puede superar los 72 caracteres
|
||||
password_too_weak: "La contraseña debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
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.
|
||||
password_update_failed: No se pudo actualizar la contraseña
|
||||
replay:
|
||||
download_unavailable: Descarga no disponible
|
||||
not_available: Repetición no disponible
|
||||
|
||||
@@ -1,4 +1,26 @@
|
||||
fr:
|
||||
password_policy:
|
||||
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
|
||||
activerecord:
|
||||
attributes:
|
||||
user:
|
||||
password: Mot de passe
|
||||
admin_account:
|
||||
password: Mot de passe
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
club:
|
||||
back_to_club: "← Club"
|
||||
sport_label: Sport principal
|
||||
@@ -60,12 +82,12 @@ fr:
|
||||
first_team_name_label: Nom de l'équipe
|
||||
first_team_name_placeholder: "ex. Under 15"
|
||||
plan_label: "Forfait de départ (pour tout le club)"
|
||||
plan_free: "Gratuit — 1 responsable diffusion par équipe, 1 direct"
|
||||
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 — 40 €/an (Light) ou 200 €/an (Full)"
|
||||
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)"
|
||||
interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
|
||||
interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
|
||||
submit: Créer le club
|
||||
billing:
|
||||
title: "Abonnement — %{name}"
|
||||
@@ -401,6 +423,9 @@ fr:
|
||||
upgrade_info_line: "Upgrade (forfait supérieur ou passage à la facturation annuelle) : la différence est débitée immédiatement sur la carte Stripe enregistrée."
|
||||
downgrade_info_line: "Downgrade entre forfaits payants (ex. Full → Light) : le nouveau forfait est actif dès le prochain renouvellement ; jusque-là, les fonctionnalités du forfait actuel restent actives, sans débit supplémentaire pour l'instant."
|
||||
already_requested_line: "Vous avez déjà demandé %{plan} à partir du %{when} : jusque-là, %{current_plan} reste actif."
|
||||
prices:
|
||||
yearly: "%{amount}/an"
|
||||
monthly: "%{amount}/mois"
|
||||
regia:
|
||||
meta_title_fallback: "Régie — Match Live TV"
|
||||
title: "Régie — %{home} vs %{away}"
|
||||
@@ -600,12 +625,29 @@ fr:
|
||||
welcome_back: Bon retour !
|
||||
invalid_credentials: E-mail ou mot de passe invalide
|
||||
logged_out: Déconnecté
|
||||
unauthorized: Non autorisé
|
||||
invalid_token: Jeton invalide
|
||||
password_resets:
|
||||
email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe.
|
||||
invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe.
|
||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_too_short: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
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.
|
||||
accounts:
|
||||
name_required: Le nom est obligatoire
|
||||
profile_updated: Profil mis à jour.
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le mot de passe doit contenir au moins 8 caractères
|
||||
password_too_long: Le mot de passe ne peut pas dépasser 72 caractères
|
||||
password_too_weak: "Le mot de passe doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
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.
|
||||
password_update_failed: Impossible de mettre à jour le mot de passe
|
||||
replay:
|
||||
download_unavailable: Téléchargement non disponible
|
||||
not_available: Replay non disponible
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
it:
|
||||
password_policy:
|
||||
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
|
||||
activerecord:
|
||||
errors:
|
||||
models:
|
||||
user:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
admin_account:
|
||||
attributes:
|
||||
password:
|
||||
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"
|
||||
club:
|
||||
back_to_club: "← Società"
|
||||
sport_label: Sport principale
|
||||
@@ -60,12 +77,12 @@ it:
|
||||
first_team_name_label: Nome squadra
|
||||
first_team_name_placeholder: "es. Under 15"
|
||||
plan_label: "Piano iniziale (per tutta la società)"
|
||||
plan_free: "Free — 1 responsabile trasmissione per squadra, 1 live"
|
||||
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 — €40/anno (Light) o €200/anno (Full)"
|
||||
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)"
|
||||
interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
|
||||
interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
|
||||
submit: Crea società
|
||||
billing:
|
||||
title: "Abbonamento — %{name}"
|
||||
@@ -401,6 +418,9 @@ it:
|
||||
upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe."
|
||||
downgrade_info_line: "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora."
|
||||
already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}."
|
||||
prices:
|
||||
yearly: "%{amount}/anno"
|
||||
monthly: "%{amount}/mese"
|
||||
regia:
|
||||
meta_title_fallback: "Regia — Match Live TV"
|
||||
title: "Regia — %{home} vs %{away}"
|
||||
@@ -600,12 +620,34 @@ it:
|
||||
welcome_back: Bentornato!
|
||||
invalid_credentials: Email o password non validi
|
||||
logged_out: Disconnesso
|
||||
unauthorized: Non autorizzato
|
||||
invalid_token: Token non valido
|
||||
password_resets:
|
||||
email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password.
|
||||
invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password.
|
||||
password_min_length: La password deve avere almeno 8 caratteri
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
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.
|
||||
accounts:
|
||||
name_required: Il nome è obbligatorio
|
||||
profile_updated: Profilo aggiornato.
|
||||
password_current_incorrect: La password attuale non è corretta
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_same_as_current: La nuova password deve essere diversa da quella attuale
|
||||
password_too_long: La password non può superare i 72 caratteri
|
||||
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.
|
||||
password_update_failed: Impossibile aggiornare la password
|
||||
replay:
|
||||
download_unavailable: Download non disponibile
|
||||
not_available: Replay non disponibile
|
||||
|
||||
@@ -195,3 +195,18 @@ de:
|
||||
s6_body_html: "Um die von der DSGVO vorgesehenen Rechte auszuüben (Zugang, Löschung, Widerspruch, Widerruf der Einwilligung), schreiben Sie an %{email_link}. Details finden Sie im %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: Datenschutzdokument
|
||||
manage_button: Cookie-Einstellungen verwalten
|
||||
support:
|
||||
title: Match Live TV Support
|
||||
meta_description: "Match Live TV Hilfe: Zugangsprobleme, Livestreams, Teamverwaltung und Support-Kontaktdaten."
|
||||
h1: Match Live TV Support
|
||||
intro: "Brauchen Sie Hilfe mit Match Live TV? Bei Problemen mit dem Zugang, der Einrichtung von Livestreams, der Spielverwaltung oder der Nutzung der App können Sie unseren Support kontaktieren."
|
||||
email_label: "Support-E-Mail:"
|
||||
access_title: Zugangsprobleme
|
||||
access_body: "Wenn Sie sich nicht anmelden können, prüfen Sie die Zugangsdaten, die Sie von Ihrem Sportverein erhalten haben. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support."
|
||||
live_title: Probleme während eines Livestreams
|
||||
live_body: "Prüfen Sie Ihre Internetverbindung und versuchen Sie erneut, die Übertragung in der App zu starten. Wenn das Problem weiterhin besteht, kontaktieren Sie den Support und geben Sie Gerät, App-Version und eine kurze Beschreibung des Problems an."
|
||||
team_title: Teamverwaltung
|
||||
team_body: "Konten und Übertragungsberechtigungen werden vom Sportverein verwaltet."
|
||||
privacy_title: Datenschutz
|
||||
privacy_body_html: "Informationen zur Verarbeitung personenbezogener Daten finden Sie auf der Seite %{privacy_link}."
|
||||
privacy_link_text: Datenschutz
|
||||
|
||||
@@ -195,3 +195,18 @@ en:
|
||||
s6_body_html: "To exercise the rights provided by the GDPR (access, erasure, objection, withdrawal of consent) write to %{email_link}. Details are in the %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: privacy document
|
||||
manage_button: Manage cookie preferences
|
||||
support:
|
||||
title: Match Live TV Support
|
||||
meta_description: "Match Live TV help: access issues, live streaming, team management and support contact details."
|
||||
h1: Match Live TV Support
|
||||
intro: "Need help with Match Live TV? For access issues, live stream setup, match management or app usage, you can contact our support team."
|
||||
email_label: "Support email:"
|
||||
access_title: Access issues
|
||||
access_body: "If you cannot sign in, check the credentials provided by your sports club. If the problem persists, contact support."
|
||||
live_title: Issues during a live stream
|
||||
live_body: "Check your Internet connection and try starting the broadcast from the app again. If the problem persists, contact support and include your device, app version and a short description of the issue."
|
||||
team_title: Team management
|
||||
team_body: "Accounts and broadcasting permissions are managed by the sports club."
|
||||
privacy_title: Privacy
|
||||
privacy_body_html: "For information on personal data processing, see the %{privacy_link} page."
|
||||
privacy_link_text: Privacy
|
||||
|
||||
@@ -195,3 +195,18 @@ es:
|
||||
s6_body_html: "Para ejercer los derechos previstos por el RGPD (acceso, supresión, oposición, revocación del consentimiento) escribe a %{email_link}. Más detalles en el %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: documento de privacidad
|
||||
manage_button: Gestionar preferencias de cookies
|
||||
support:
|
||||
title: Soporte Match Live TV
|
||||
meta_description: "Ayuda de Match Live TV: problemas de acceso, directos, gestión del equipo y datos de contacto del soporte."
|
||||
h1: Soporte Match Live TV
|
||||
intro: "¿Necesitas ayuda con Match Live TV? Para problemas de acceso, configuración de directos, gestión de partidos o uso de la app puedes contactar con nuestro soporte."
|
||||
email_label: "Correo de soporte:"
|
||||
access_title: Problemas de acceso
|
||||
access_body: "Si no puedes acceder, verifica las credenciales recibidas de tu club deportivo. Si el problema continúa, contacta con el soporte."
|
||||
live_title: Problemas durante un directo
|
||||
live_body: "Verifica la conexión a Internet e intenta de nuevo iniciar la transmisión desde la app. Si el problema continúa, contacta con el soporte indicando el dispositivo, la versión de la app y una breve descripción del problema."
|
||||
team_title: Gestión del equipo
|
||||
team_body: "Las cuentas y los permisos de transmisión los gestiona el club deportivo."
|
||||
privacy_title: Privacidad
|
||||
privacy_body_html: "Para información sobre el tratamiento de datos personales, consulta la página %{privacy_link}."
|
||||
privacy_link_text: Privacidad
|
||||
|
||||
@@ -195,3 +195,18 @@ fr:
|
||||
s6_body_html: "Pour exercer les droits prévus par le RGPD (accès, effacement, opposition, retrait du consentement), écrivez à %{email_link}. Détails dans le %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: document de confidentialité
|
||||
manage_button: Gérer les préférences de cookies
|
||||
support:
|
||||
title: Support Match Live TV
|
||||
meta_description: "Assistance Match Live TV : problèmes d'accès, directs, gestion d'équipe et coordonnées du support."
|
||||
h1: Support Match Live TV
|
||||
intro: "Besoin d'aide avec Match Live TV ? Pour les problèmes d'accès, la configuration des directs, la gestion des matchs ou l'utilisation de l'application, vous pouvez contacter notre support."
|
||||
email_label: "E-mail du support :"
|
||||
access_title: Problèmes d'accès
|
||||
access_body: "Si vous ne parvenez pas à vous connecter, vérifiez les identifiants fournis par votre club sportif. Si le problème persiste, contactez le support."
|
||||
live_title: Problèmes pendant un direct
|
||||
live_body: "Vérifiez votre connexion Internet et réessayez de démarrer la diffusion depuis l'application. Si le problème persiste, contactez le support en indiquant l'appareil, la version de l'application et une brève description du problème."
|
||||
team_title: Gestion de l'équipe
|
||||
team_body: "Les comptes et les autorisations de diffusion sont gérés par le club sportif."
|
||||
privacy_title: Confidentialité
|
||||
privacy_body_html: "Pour les informations sur le traitement des données personnelles, consultez la page %{privacy_link}."
|
||||
privacy_link_text: Confidentialité
|
||||
|
||||
@@ -195,3 +195,18 @@ it:
|
||||
s6_body_html: "Per esercitare i diritti previsti dal GDPR (accesso, cancellazione, opposizione, revoca consenso) scrivi a %{email_link}. Dettagli nel %{privacy_doc_link}."
|
||||
s6_privacy_doc_link_text: documento privacy
|
||||
manage_button: Gestisci preferenze cookie
|
||||
support:
|
||||
title: Supporto Match Live TV
|
||||
meta_description: "Assistenza Match Live TV: problemi di accesso, dirette live, gestione squadra e contatti del supporto."
|
||||
h1: Supporto Match Live TV
|
||||
intro: "Hai bisogno di assistenza con Match Live TV? Per problemi di accesso, configurazione delle dirette, gestione delle partite o utilizzo dell’app puoi contattare il nostro supporto."
|
||||
email_label: "Email di supporto:"
|
||||
access_title: Problemi di accesso
|
||||
access_body: "Se non riesci ad accedere, verifica le credenziali ricevute dalla tua società sportiva. Se il problema persiste, contatta il supporto."
|
||||
live_title: Problemi durante una diretta
|
||||
live_body: "Verifica la connessione Internet e riprova ad avviare la trasmissione dall’app. Se il problema persiste, contatta il supporto indicando dispositivo, versione dell’app e una breve descrizione del problema."
|
||||
team_title: Gestione della squadra
|
||||
team_body: "Gli account e le autorizzazioni per la trasmissione sono gestiti dalla società sportiva."
|
||||
privacy_title: Privacy
|
||||
privacy_body_html: "Per informazioni sul trattamento dei dati personali consulta la pagina %{privacy_link}."
|
||||
privacy_link_text: Privacy
|
||||
|
||||
@@ -2,32 +2,80 @@ de:
|
||||
pages:
|
||||
features:
|
||||
meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV"
|
||||
meta_description: "App für Livestreams vom Handy: Spiel filmen, Spielstand aktualisieren, Link mit Eltern und Großeltern teilen. Spielarchiv und Pläne für Jugend-Sportvereine."
|
||||
title: Funktionen
|
||||
lead: "Alles, was nötig ist, damit auch die, die nicht in der Halle sein können, das Spiel verfolgen: einfach für Trainer und Helfer, praktisch für Eltern, Großeltern und Verwandte."
|
||||
staff_section_title: Für Verein und Team
|
||||
staff_invite_title: Einladungen mit eigener E-Mail
|
||||
staff_invite_body: "Der Vorsitzende oder Inhaber lädt ein, wer filmt und wer den Spielstand aktualisiert: jeder meldet sich mit seiner eigenen E-Mail an, ohne Passwörter zu teilen."
|
||||
staff_access_title: Zugriffsverwaltung
|
||||
staff_access_body: Sie können einem Helfer den Zugriff entziehen oder eine noch offene Einladung vom Team-Dashboard aus stornieren.
|
||||
staff_plans_title: Maßgeschneiderte Pläne
|
||||
staff_plans_body: "Mit Free, Premium Light oder Premium Full erhöhen Sie, wie viele Personen live helfen können und wie viele Spiele parallel laufen können."
|
||||
live_section_title: Auf dem Platz und live
|
||||
live_phone_title: Vom Handy filmen
|
||||
live_phone_body: "Handy aufstellen und den Livestream starten: auch bei schwachem 4G bleibt die Übertragung so stabil wie möglich, ohne Kabelsalat oder Mischpult."
|
||||
live_score_title: Spielstand vom zweiten Handy
|
||||
live_score_body: "Ein weiterer Helfer aktualisiert Sätze und Punkte von einem zweiten Handy aus: einfach den Code scannen, der auf dem Bildschirm des Filmenden erscheint."
|
||||
live_link_title: Link für Familien und Fans
|
||||
live_link_body: "Teilen Sie einen Link: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer, ohne App-Installation."
|
||||
live_archive_title: Spielarchiv
|
||||
live_archive_body: "Mit Premium Light oder Full bleiben die Spiele auf dem Server gespeichert (30 oder 90 Tage) und Sie können sie bei Bedarf aufs Handy herunterladen."
|
||||
live_youtube_mltv_title: Auch auf YouTube (Match Live TV)
|
||||
live_youtube_mltv_body: "Mit Premium Light ist der Livestream zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV zu sehen."
|
||||
live_youtube_club_title: YouTube-Kanal des Vereins
|
||||
live_youtube_club_body: "Mit Premium Full können Sie denselben Livestream auch auf den offiziellen YouTube-Kanal des Vereins senden."
|
||||
live_website_title: Auf der Website des Vereins
|
||||
live_website_body: "Integration zum Einbetten des Players auf der Vereinswebsite — demnächst mit Premium Full."
|
||||
cta_signup: Team registrieren
|
||||
meta_description: "Übertragen Sie Vereinsspiele vom Smartphone mit persönlichen Konten, Spielstand per Link, Live auf Match Live TV und YouTube sowie Replay und Archiv. Ohne komplizierte Technik."
|
||||
hero_title: "Die Livestreams Ihres Vereins. Ohne geteilte Passwörter, ohne komplizierte Technik."
|
||||
hero_lead: "Laden Sie Trainer, Verantwortliche und Eltern ein, mit dem eigenen Konto zu streamen. Ein Smartphone reicht: Match Live TV übernimmt Zugänge, Spielstand, Live, Replay und Veröffentlichung."
|
||||
hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
|
||||
flow_aria: Ablauf der Übertragung
|
||||
flow_phone: Ein Smartphone
|
||||
flow_account: Ein persönliches Konto
|
||||
flow_live: Sie sind live
|
||||
how_title: Von der Halle zu den Zuschauern in wenigen Minuten
|
||||
how_1_title: Spiel anlegen
|
||||
how_1_body: Der Verein bereitet das Spiel mit Team, Gegner, Datum und Uhrzeit vor.
|
||||
how_2_title: App öffnen
|
||||
how_2_body: Die beauftragte Person meldet sich in der Match-Live-TV-App mit dem eigenen persönlichen Konto an.
|
||||
how_3_title: Livestream starten
|
||||
how_3_body: Ein Smartphone reicht. Keine spezielle Ausrüstung und keine komplizierte Einrichtung.
|
||||
how_4_title: Spielstand führen
|
||||
how_4_body: Eine andere Person kann Ergebnis und Spielstand über einen einfachen Link aktualisieren – ohne App und ohne Konto.
|
||||
how_5_title: Replay
|
||||
how_5_body: Nach dem Livestream kann das Spiel gespeichert und als Replay verfügbar gemacht werden – je nach Plan.
|
||||
accounts_title: Jeder mit dem eigenen Konto. Das Vereins-Passwort bleibt beim Verein.
|
||||
accounts_lead: Username und Passwort des Hauptkontos müssen nicht mehr geteilt werden. Verantwortliche, Trainer und beauftragte Eltern erhalten einen persönlichen, widerrufbaren Zugang.
|
||||
accounts_invite_title: Einladen
|
||||
accounts_invite_body: Der Verein lädt Trainer, Verantwortliche oder Eltern ein, die die Übertragung übernehmen.
|
||||
accounts_transmit_title: Übertragen
|
||||
accounts_transmit_body: Jeder meldet sich in der App mit den eigenen Zugangsdaten an.
|
||||
accounts_revoke_title: Widerrufen
|
||||
accounts_revoke_body: Wenn jemand nicht mehr mit Team oder Verein zusammenarbeitet, kann der Zugang widerrufen werden, ohne das Passwort der anderen zu ändern.
|
||||
accounts_highlight: Keine geteilten Passwörter. Jede beauftragte Person hat den eigenen persönlichen Zugang.
|
||||
score_title: Wer filmt, filmt. Wer den Spielstand führt, führt den Spielstand.
|
||||
score_lead: Wer überträgt, kann sich auf den Bildausschnitt konzentrieren. Eine andere Person – auch von der Tribüne – öffnet einen einfachen Link und aktualisiert den Spielstand in Echtzeit.
|
||||
score_item_app: Keine App-Installation
|
||||
score_item_signup: Keine Registrierung
|
||||
score_item_password: Kein Passwort
|
||||
score_item_link: Einfach den geteilten Link öffnen
|
||||
score_card_aria: Zugang zur Spielstand-Regie
|
||||
score_card_title: Regie per Link
|
||||
score_card_body: Wer Sätze und Punkte aktualisiert, öffnet einen geteilten Link. Kein Konto, keine Einladung.
|
||||
access_link: Zugang per Link
|
||||
distribute_title: Ein Spiel, mehrere Wege es zu verfolgen
|
||||
distribute_lead: Vom Smartphone in der Halle erreicht der Livestream die Zuschauer auf Match Live TV und – je nach Plan – auch YouTube.
|
||||
distribute_aria: Wohin der Livestream geht
|
||||
distribute_phone: Smartphone
|
||||
distribute_mltv: Match Live TV
|
||||
distribute_viewers: Zuschauer
|
||||
distribute_youtube: YouTube
|
||||
youtube_badge: Premium Full
|
||||
youtube_title: Der Livestream bleibt bei Ihrem Verein
|
||||
youtube_body: Mit Premium Full können Sie auch auf den offiziellen YouTube-Kanal des Vereins streamen. So behält der Verein Publikum, Abonnenten, Aufrufe, Spielarchiv und Sponsorensichtbarkeit.
|
||||
youtube_mock_channel: Offizieller Vereinskanal
|
||||
youtube_mock_subs: Abonnenten und Aufrufe des Vereins
|
||||
replay_title: Das Spiel endet. Die Erinnerung bleibt.
|
||||
replay_lead: Bei Plänen mit Replay bleiben Spiele auf den Match-Live-TV-Servern gespeichert und können später angesehen werden. Die Aufbewahrungsdauer hängt vom Plan ab.
|
||||
replay_item_server: Speicherung auf Match-Live-TV-Servern
|
||||
replay_item_plan: Archivdauer je nach Plan
|
||||
replay_item_download: Video bei Bedarf aufs Handy herunterladen
|
||||
replay_card_title: Spielarchiv
|
||||
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
|
||||
replay_mock_1_title: Rossi vs Neri
|
||||
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||
replay_mock_2_title: Rossi vs Blu
|
||||
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||
replay_mock_3_title: Rossi vs Bianchi
|
||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||
more_title: Auch das gehört zum Produkt
|
||||
more_stable_title: Livestream vom Smartphone
|
||||
more_stable_body: Handy aufstellen und Übertragung starten. Auch bei schwachem 4G bleibt der Livestream so stabil wie möglich – ohne Kabel oder Mischpult.
|
||||
more_link_title: Link für Familien und Fans
|
||||
more_link_body: "Einen Link teilen: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer – ohne App."
|
||||
more_yt_mltv_title: Auch auf YouTube Match Live TV
|
||||
more_yt_mltv_body: Der Livestream kann zusätzlich zum Link auf der Website auch auf dem YouTube-Kanal von Match Live TV sichtbar sein.
|
||||
cta_title: Bereit für das nächste Spiel?
|
||||
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
|
||||
cta_primary: Kostenlos starten
|
||||
cta_secondary: Pläne vergleichen
|
||||
pricing:
|
||||
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV"
|
||||
meta_description: "Free-, Premium-Light- und Premium-Full-Pläne für Livestreams und Spielarchiv. Jahresabo für Vereine: mehr Team-Mitglieder, mehr parallele Spiele, Replay und YouTube."
|
||||
@@ -35,8 +83,8 @@ de:
|
||||
club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}"
|
||||
manage_subscription: Abo verwalten
|
||||
team_title: Pläne für Ihr Team
|
||||
team_lead: "Jahresabo für Vereine: die Streaming-Verantwortlichen starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
|
||||
table_staff: Streaming-Verantwortliche / Jahr
|
||||
team_lead: "Jahresabo für Vereine: die Streaming-Konten starten den Livestream aus der App; der Spielstand wird über einen teilbaren Link verwaltet."
|
||||
table_staff: Streaming-Konten
|
||||
table_matches: Gleichzeitige Spiele
|
||||
table_live_mltv: Live auf Match Live TV
|
||||
table_youtube: YouTube
|
||||
@@ -44,8 +92,7 @@ de:
|
||||
table_download: Handy-Download
|
||||
table_price: Preis
|
||||
table_price_free: "€0"
|
||||
table_price_light_html: "€40/Jahr oder €5/Monat"
|
||||
table_price_full_html: "€200/Jahr oder €20/Monat"
|
||||
table_price_note: "Listenpreis %{list} — %{monthly}"
|
||||
table_yes: Ja
|
||||
table_no: Nein
|
||||
table_unlimited: Unbegrenzt
|
||||
@@ -55,8 +102,16 @@ de:
|
||||
contact_button: Kontaktieren Sie uns
|
||||
plans:
|
||||
price_alt_html: "oder <strong>%{price}</strong>"
|
||||
staff_html: "Streaming-Verantwortliche: <strong>%{count}</strong> / Jahr pro Team"
|
||||
price_equiv: "entspricht %{price}"
|
||||
launch_price: Einführungspreis
|
||||
recommended_badge: Empfohlen für Vereine
|
||||
cta_name:
|
||||
premium_light: Light
|
||||
premium_full: Full
|
||||
staff_html: "Streaming-Konten: maximal <strong>%{count}</strong> pro Abonnement"
|
||||
staff_unlimited_html: "Streaming-Konten: <strong>unbegrenzt</strong> pro Abonnement"
|
||||
staff_unlimited: unbegrenzt
|
||||
staff_note_full: "Keine geteilten Passwörter: jede Person nutzt den eigenen Zugang, jederzeit widerrufbar."
|
||||
matches_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)"
|
||||
matches_unlimited: unbegrenzt
|
||||
replay_html: "Replay: %{value}"
|
||||
|
||||
@@ -2,32 +2,80 @@ en:
|
||||
pages:
|
||||
features:
|
||||
meta_title: "Youth match streaming features — Match Live TV"
|
||||
meta_description: "App for live streaming from your phone: film the match, update the score, share the link with parents and grandparents. Match archive and plans for youth sports clubs."
|
||||
title: Features
|
||||
lead: "Everything you need so people who can't be at the gym can follow the match: simple for coaches and volunteers, convenient for parents, grandparents and relatives."
|
||||
staff_section_title: For the club and staff
|
||||
staff_invite_title: Invites with their own email
|
||||
staff_invite_body: "The president or owner invites whoever films and whoever updates the score: each person signs in with their own email, no shared passwords."
|
||||
staff_access_title: Access management
|
||||
staff_access_body: You can remove a volunteer's access or cancel a pending invite from the team dashboard.
|
||||
staff_plans_title: Plans to fit your needs
|
||||
staff_plans_body: With Free, Premium Light or Premium Full you increase how many people can help stream live and how many matches can run in parallel.
|
||||
live_section_title: On the court and live
|
||||
live_phone_title: Film from your phone
|
||||
live_phone_body: "Prop up the phone and start the stream: even with weak 4G the broadcast stays as stable as possible, with no cables or mixers to worry about."
|
||||
live_score_title: Score from a second phone
|
||||
live_score_body: "Another volunteer updates sets and points from a second phone: just scan the code shown on the streamer's screen."
|
||||
live_link_title: Link for families and fans
|
||||
live_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
|
||||
live_archive_title: Match archive
|
||||
live_archive_body: "With Premium Light or Full, matches stay saved on the server (30 or 90 days) and you can download them to your phone whenever you need."
|
||||
live_youtube_mltv_title: Also on YouTube (Match Live TV)
|
||||
live_youtube_mltv_body: With Premium Light the stream is also visible on the Match Live TV YouTube channel, in addition to the link on the site.
|
||||
live_youtube_club_title: Your club's YouTube channel
|
||||
live_youtube_club_body: With Premium Full you can send the same stream to your club's official YouTube channel.
|
||||
live_website_title: On the club's website
|
||||
live_website_body: Integration to embed the player on the club's website — coming with Premium Full.
|
||||
cta_signup: Register your team
|
||||
meta_description: "Stream your club's matches from a smartphone with personal accounts, score via link, live on Match Live TV and YouTube, plus replay and archive. No complicated equipment."
|
||||
hero_title: "Your club's live streams. No shared passwords, no complicated gear."
|
||||
hero_lead: "Invite coaches, managers and parents to stream with their own account. All you need is a smartphone: Match Live TV handles access, scoring, the live stream, replay and publishing."
|
||||
hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
|
||||
flow_aria: Streaming flow
|
||||
flow_phone: One smartphone
|
||||
flow_account: One personal account
|
||||
flow_live: You're live
|
||||
how_title: From the gym to viewers in a few minutes
|
||||
how_1_title: Create the match
|
||||
how_1_body: The club sets up the match with team, opponent, date and time.
|
||||
how_2_title: Open the app
|
||||
how_2_body: The assigned person signs into the Match Live TV app with their own personal account.
|
||||
how_3_title: Start the stream
|
||||
how_3_body: A smartphone is enough. No dedicated equipment or complicated setup.
|
||||
how_4_title: Manage the score
|
||||
how_4_body: Someone else can update the result and score through a simple link, without installing an app or creating an account.
|
||||
how_5_title: Replay
|
||||
how_5_body: After the stream ends, the match can be kept and made available as a replay, depending on the plan.
|
||||
accounts_title: Everyone with their own account. The club password stays with the club.
|
||||
accounts_lead: You no longer need to share the main club username and password. Managers, coaches and assigned parents get a personal, revocable login.
|
||||
accounts_invite_title: Invite
|
||||
accounts_invite_body: The club invites coaches, managers or parents assigned to stream.
|
||||
accounts_transmit_title: Stream
|
||||
accounts_transmit_body: Each person signs into the app with their own credentials.
|
||||
accounts_revoke_title: Revoke
|
||||
accounts_revoke_body: When someone no longer works with the team or club, their access can be revoked without changing everyone else's password.
|
||||
accounts_highlight: No shared passwords. Every assigned person has their own personal access.
|
||||
score_title: Who films, films. Who keeps score, keeps score.
|
||||
score_lead: The person streaming can focus on the framing. Someone else, even from the stands, can open a simple link and update the score in real time.
|
||||
score_item_app: No app to install
|
||||
score_item_signup: No sign-up
|
||||
score_item_password: No password
|
||||
score_item_link: Just open the shared link
|
||||
score_card_aria: Score control access
|
||||
score_card_title: Score control via a link
|
||||
score_card_body: Whoever updates sets and points opens a shared link. No account and no invite.
|
||||
access_link: Open with a link
|
||||
distribute_title: One match, more ways to follow it
|
||||
distribute_lead: From the smartphone in the gym, the stream reaches viewers on Match Live TV and, when included in the plan, also on YouTube.
|
||||
distribute_aria: Where the stream goes
|
||||
distribute_phone: Smartphone
|
||||
distribute_mltv: Match Live TV
|
||||
distribute_viewers: Viewers
|
||||
distribute_youtube: YouTube
|
||||
youtube_badge: Premium Full
|
||||
youtube_title: The stream stays with your club
|
||||
youtube_body: With Premium Full you can also stream to the club's official YouTube channel. The club keeps its audience, subscribers, views, match archive and sponsor visibility.
|
||||
youtube_mock_channel: Official club channel
|
||||
youtube_mock_subs: Club subscribers and views
|
||||
replay_title: The match ends. The memory stays.
|
||||
replay_lead: On plans that include replay, matches are stored on Match Live TV servers and can be watched later. How long they are kept depends on the plan.
|
||||
replay_item_server: Stored on Match Live TV servers
|
||||
replay_item_plan: Archive length based on the plan
|
||||
replay_item_download: Download the video to your phone when you need it
|
||||
replay_card_title: Match archive
|
||||
replay_card_body: Rewatch matches after the final whistle and download them when you need them.
|
||||
replay_mock_1_title: Rossi vs Neri
|
||||
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||
replay_mock_2_title: Rossi vs Blu
|
||||
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||
replay_mock_3_title: Rossi vs Bianchi
|
||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||
more_title: Also part of the product
|
||||
more_stable_title: Stream from your smartphone
|
||||
more_stable_body: Prop up the phone and start streaming. Even with weak 4G the broadcast stays as stable as possible, with no cables or mixers.
|
||||
more_link_title: Link for families and fans
|
||||
more_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install."
|
||||
more_yt_mltv_title: Also on Match Live TV YouTube
|
||||
more_yt_mltv_body: You can also make the stream visible on the Match Live TV YouTube channel, in addition to the link on the site.
|
||||
cta_title: Ready for the next match?
|
||||
cta_body: Try Match Live TV for free. No dedicated equipment needed.
|
||||
cta_primary: Start for free
|
||||
cta_secondary: Compare plans
|
||||
pricing:
|
||||
meta_title: "Youth match streaming pricing — Match Live TV"
|
||||
meta_description: "Free, Premium Light and Premium Full plans for live streaming and match archive. Annual subscription for clubs: more staff, more concurrent matches, replay and YouTube."
|
||||
@@ -35,8 +83,8 @@ en:
|
||||
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
|
||||
manage_subscription: Manage subscription
|
||||
team_title: Plans for your team
|
||||
team_lead: "Annual subscription for clubs: streaming staff start the broadcast from the app; the score is managed with a shareable link."
|
||||
table_staff: Streaming staff / year
|
||||
team_lead: "Annual subscription for clubs: transmitter accounts start the broadcast from the app; the score is managed with a shareable link."
|
||||
table_staff: Transmitter accounts
|
||||
table_matches: Concurrent matches
|
||||
table_live_mltv: Live on Match Live TV
|
||||
table_youtube: YouTube
|
||||
@@ -44,8 +92,7 @@ en:
|
||||
table_download: Phone download
|
||||
table_price: Price
|
||||
table_price_free: "€0"
|
||||
table_price_light_html: "€40/year or €5/month"
|
||||
table_price_full_html: "€200/year or €20/month"
|
||||
table_price_note: "list %{list} — %{monthly}"
|
||||
table_yes: "Yes"
|
||||
table_no: "No"
|
||||
table_unlimited: Unlimited
|
||||
@@ -55,8 +102,16 @@ en:
|
||||
contact_button: Contact us
|
||||
plans:
|
||||
price_alt_html: "or <strong>%{price}</strong>"
|
||||
staff_html: "Streaming staff: <strong>%{count}</strong> / year per team"
|
||||
price_equiv: "equivalent to %{price}"
|
||||
launch_price: Launch price
|
||||
recommended_badge: Recommended for clubs
|
||||
cta_name:
|
||||
premium_light: Light
|
||||
premium_full: Full
|
||||
staff_html: "Transmitter accounts: max <strong>%{count}</strong> per subscription"
|
||||
staff_unlimited_html: "Transmitter accounts: <strong>unlimited</strong> per subscription"
|
||||
staff_unlimited: unlimited
|
||||
staff_note_full: "No shared passwords: each person uses their own login, which can be revoked at any time."
|
||||
matches_html: "Matches: <strong>%{count}</strong> concurrent (whole club)"
|
||||
matches_unlimited: unlimited
|
||||
replay_html: "Replay: %{value}"
|
||||
|
||||
@@ -2,32 +2,80 @@ es:
|
||||
pages:
|
||||
features:
|
||||
meta_title: "Funciones de streaming de partidos juveniles — Match Live TV"
|
||||
meta_description: "App para el directo desde el móvil: graba el partido, actualiza el marcador, comparte el enlace con padres y abuelos. Archivo de partidos y planes para clubes deportivos juveniles."
|
||||
title: Funciones
|
||||
lead: "Todo lo necesario para que quienes no pueden estar en el pabellón sigan el partido: sencillo para entrenadores y voluntarios, cómodo para padres, abuelos y familiares."
|
||||
staff_section_title: Para el club y el staff
|
||||
staff_invite_title: Invitaciones con su propio email
|
||||
staff_invite_body: "El presidente o propietario invita a quien graba y a quien actualiza el marcador: cada uno accede con su propio email, sin compartir contraseñas."
|
||||
staff_access_title: Gestión de accesos
|
||||
staff_access_body: Puedes retirar el acceso a un voluntario o cancelar una invitación pendiente desde el panel del equipo.
|
||||
staff_plans_title: Planes a medida
|
||||
staff_plans_body: Con Free, Premium Light o Premium Full aumentas cuántas personas pueden ayudar en directo y cuántos partidos pueden ir en paralelo.
|
||||
live_section_title: En la cancha y en directo
|
||||
live_phone_title: Graba desde el móvil
|
||||
live_phone_body: "Apoyas el móvil e inicias el directo: incluso con 4G débil, la transmisión se mantiene lo más estable posible, sin líos de cables ni mezcladores."
|
||||
live_score_title: Marcador desde un segundo móvil
|
||||
live_score_body: "Otro voluntario actualiza sets y puntos desde un segundo móvil: solo hay que escanear el código que aparece en la pantalla de quien graba."
|
||||
live_link_title: Enlace para familias y aficionados
|
||||
live_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
|
||||
live_archive_title: Archivo de partidos
|
||||
live_archive_body: "Con Premium Light o Full los partidos quedan guardados en el servidor (30 o 90 días) y puedes descargarlos al móvil cuando quieras."
|
||||
live_youtube_mltv_title: También en YouTube (Match Live TV)
|
||||
live_youtube_mltv_body: "Con Premium Light el directo también se ve en el canal de YouTube de Match Live TV, además del enlace en el sitio."
|
||||
live_youtube_club_title: Canal de YouTube del club
|
||||
live_youtube_club_body: "Con Premium Full puedes enviar el mismo directo también al canal oficial de YouTube del club."
|
||||
live_website_title: En el sitio web del club
|
||||
live_website_body: "Integración para incrustar el reproductor en el sitio web del club — próximamente con Premium Full."
|
||||
cta_signup: Registra tu equipo
|
||||
meta_description: "Emite los partidos del club desde el móvil con cuentas personales, marcador por enlace, directo en Match Live TV y YouTube, más repetición y archivo. Sin equipo complicado."
|
||||
hero_title: "Los directos de tu club. Sin contraseñas compartidas, sin equipo complicado."
|
||||
hero_lead: "Invita a entrenadores, directivos y padres a emitir con su propia cuenta. Basta un smartphone: Match Live TV gestiona accesos, marcador, directo, repetición y publicación."
|
||||
hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
|
||||
flow_aria: Flujo de la emisión
|
||||
flow_phone: Un smartphone
|
||||
flow_account: Una cuenta personal
|
||||
flow_live: Estás en directo
|
||||
how_title: Del pabellón a los espectadores en pocos minutos
|
||||
how_1_title: Crea el partido
|
||||
how_1_body: El club prepara el partido indicando equipo, rival, fecha y hora.
|
||||
how_2_title: Abre la app
|
||||
how_2_body: La persona encargada accede a la app Match Live TV con su propia cuenta personal.
|
||||
how_3_title: Empieza el directo
|
||||
how_3_body: Basta el smartphone. Sin equipo dedicado ni configuración complicada.
|
||||
how_4_title: Gestiona el marcador
|
||||
how_4_body: Otra persona puede actualizar resultado y marcador con un enlace sencillo, sin instalar apps ni crear una cuenta.
|
||||
how_5_title: Repetición
|
||||
how_5_body: Al terminar el directo, el partido puede conservarse y quedar disponible como repetición, según el plan elegido.
|
||||
accounts_title: Cada uno con su propia cuenta. La contraseña del club se queda en el club.
|
||||
accounts_lead: Ya no hace falta compartir el usuario y la contraseña de la cuenta principal. Directivos, entrenadores y padres encargados reciben un acceso personal y revocable.
|
||||
accounts_invite_title: Invita
|
||||
accounts_invite_body: El club invita a entrenadores, directivos o padres encargados de la emisión.
|
||||
accounts_transmit_title: Emite
|
||||
accounts_transmit_body: Cada uno accede a la app con sus propias credenciales.
|
||||
accounts_revoke_title: Revoca
|
||||
accounts_revoke_body: Cuando una persona deja de colaborar con el equipo o el club, su acceso puede revocarse sin cambiar la contraseña a los demás.
|
||||
accounts_highlight: Sin contraseñas compartidas. Cada encargado tiene su propio acceso personal.
|
||||
score_title: Quien graba, graba. Quien lleva el marcador, lleva el marcador.
|
||||
score_lead: Quien emite puede centrarse en el encuadre. Otra persona, incluso desde la grada, puede abrir un enlace sencillo y actualizar el marcador en tiempo real.
|
||||
score_item_app: Sin app que instalar
|
||||
score_item_signup: Sin registro
|
||||
score_item_password: Sin contraseña
|
||||
score_item_link: Basta abrir el enlace compartido
|
||||
score_card_aria: Acceso a la regia del marcador
|
||||
score_card_title: Regia con un enlace
|
||||
score_card_body: Quien actualiza sets y puntos abre un enlace compartido. Sin cuenta y sin invitación.
|
||||
access_link: Acceso con enlace
|
||||
distribute_title: Un partido, más formas de seguirlo
|
||||
distribute_lead: Desde el smartphone en el pabellón, el directo llega a los espectadores en Match Live TV y, según el plan, también a YouTube.
|
||||
distribute_aria: Dónde llega el directo
|
||||
distribute_phone: Smartphone
|
||||
distribute_mltv: Match Live TV
|
||||
distribute_viewers: Espectadores
|
||||
distribute_youtube: YouTube
|
||||
youtube_badge: Premium Full
|
||||
youtube_title: El directo se queda en tu club
|
||||
youtube_body: Con Premium Full también puedes emitir en el canal oficial de YouTube del club. Así el club conserva su público, suscriptores, visualizaciones, archivo de partidos y visibilidad de patrocinadores.
|
||||
youtube_mock_channel: Canal oficial del club
|
||||
youtube_mock_subs: Suscriptores y visualizaciones del club
|
||||
replay_title: El partido termina. El recuerdo permanece.
|
||||
replay_lead: En los planes con repetición, los partidos se guardan en los servidores de Match Live TV y pueden verse después. La duración del archivo depende del plan.
|
||||
replay_item_server: Conservación en servidores Match Live TV
|
||||
replay_item_plan: Duración del archivo según el plan
|
||||
replay_item_download: Descarga del vídeo al móvil cuando haga falta
|
||||
replay_card_title: Archivo de partidos
|
||||
replay_card_body: Vuelve a ver los partidos tras el pitido final y descárgalos cuando los necesites.
|
||||
replay_mock_1_title: Rossi vs Neri
|
||||
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||
replay_mock_2_title: Rossi vs Blu
|
||||
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||
replay_mock_3_title: Rossi vs Bianchi
|
||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||
more_title: También forma parte del producto
|
||||
more_stable_title: Directo desde el smartphone
|
||||
more_stable_body: Apoyas el móvil e inicias la emisión. Incluso con 4G débil, el directo se mantiene lo más estable posible, sin cables ni mezcladores.
|
||||
more_link_title: Enlace para familias y aficionados
|
||||
more_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps."
|
||||
more_yt_mltv_title: También en YouTube Match Live TV
|
||||
more_yt_mltv_body: También puedes hacer visible el directo en el canal de YouTube de Match Live TV, además del enlace en el sitio.
|
||||
cta_title: ¿Listos para el próximo partido?
|
||||
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
|
||||
cta_primary: Empieza gratis
|
||||
cta_secondary: Compara los planes
|
||||
pricing:
|
||||
meta_title: "Precios de streaming de partidos juveniles — Match Live TV"
|
||||
meta_description: "Planes Free, Premium Light y Premium Full para directos y archivo de partidos. Suscripción anual para clubes: más staff, más partidos en paralelo, repetición y YouTube."
|
||||
@@ -35,8 +83,8 @@ es:
|
||||
club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
|
||||
manage_subscription: Gestionar suscripción
|
||||
team_title: Planes para tu equipo
|
||||
team_lead: "Suscripción anual para clubes: los responsables de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
|
||||
table_staff: Responsables de emisión / año
|
||||
team_lead: "Suscripción anual para clubes: las cuentas de emisión inician el directo desde la app; el marcador se gestiona con un enlace para compartir."
|
||||
table_staff: Cuentas de emisión
|
||||
table_matches: Partidos simultáneos
|
||||
table_live_mltv: Directo en Match Live TV
|
||||
table_youtube: YouTube
|
||||
@@ -44,8 +92,7 @@ es:
|
||||
table_download: Descarga al móvil
|
||||
table_price: Precio
|
||||
table_price_free: "€0"
|
||||
table_price_light_html: "€40/año o €5/mes"
|
||||
table_price_full_html: "€200/año o €20/mes"
|
||||
table_price_note: "tarifa %{list} — %{monthly}"
|
||||
table_yes: "Sí"
|
||||
table_no: "No"
|
||||
table_unlimited: Ilimitado
|
||||
@@ -55,8 +102,16 @@ es:
|
||||
contact_button: Contáctanos
|
||||
plans:
|
||||
price_alt_html: "o <strong>%{price}</strong>"
|
||||
staff_html: "Responsables de emisión: <strong>%{count}</strong> / año por equipo"
|
||||
staff_unlimited: ilimitado
|
||||
price_equiv: "equivale a %{price}"
|
||||
launch_price: Precio de lanzamiento
|
||||
recommended_badge: Recomendado para clubes
|
||||
cta_name:
|
||||
premium_light: Light
|
||||
premium_full: Full
|
||||
staff_html: "Cuentas de emisión: máximo <strong>%{count}</strong> por suscripción"
|
||||
staff_unlimited_html: "Cuentas de emisión: <strong>ilimitadas</strong> por suscripción"
|
||||
staff_unlimited: ilimitadas
|
||||
staff_note_full: "Sin contraseñas compartidas: cada persona usa su propio acceso, revocable en cualquier momento."
|
||||
matches_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)"
|
||||
matches_unlimited: ilimitados
|
||||
replay_html: "Repetición: %{value}"
|
||||
|
||||
@@ -2,32 +2,80 @@ fr:
|
||||
pages:
|
||||
features:
|
||||
meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV"
|
||||
meta_description: "Application pour le direct depuis le téléphone : filmez le match, mettez à jour le score, partagez le lien avec parents et grands-parents. Archive des matchs et offres pour les clubs sportifs jeunes."
|
||||
title: Fonctionnalités
|
||||
lead: "Tout ce qu'il faut pour faire suivre les matchs à ceux qui ne peuvent pas être en salle : simple pour les coachs et bénévoles, pratique pour parents, grands-parents et proches."
|
||||
staff_section_title: Pour le club et le staff
|
||||
staff_invite_title: Invitations avec leur propre email
|
||||
staff_invite_body: "Le président ou le propriétaire invite qui filme et qui met à jour le score : chacun se connecte avec son propre email, sans partager de mot de passe."
|
||||
staff_access_title: Gestion des accès
|
||||
staff_access_body: Vous pouvez retirer l'accès à un bénévole ou annuler une invitation encore en attente depuis le tableau de bord de l'équipe.
|
||||
staff_plans_title: Des offres sur mesure
|
||||
staff_plans_body: "Avec Free, Premium Light ou Premium Full, augmentez le nombre de personnes qui peuvent aider en direct et le nombre de matchs en parallèle."
|
||||
live_section_title: Sur le terrain et en direct
|
||||
live_phone_title: Filmez depuis votre téléphone
|
||||
live_phone_body: "Posez le téléphone et lancez le direct : même avec un 4G faible, la diffusion reste aussi stable que possible, sans câbles ni mixeur."
|
||||
live_score_title: Score depuis un second téléphone
|
||||
live_score_body: "Un autre bénévole met à jour les sets et les points depuis un second téléphone : il suffit de scanner le code affiché sur l'écran de celui qui filme."
|
||||
live_link_title: Lien pour les familles et supporters
|
||||
live_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
|
||||
live_archive_title: Archive des matchs
|
||||
live_archive_body: "Avec Premium Light ou Full, les matchs restent enregistrés sur le serveur (30 ou 90 jours) et vous pouvez les télécharger sur le téléphone quand vous voulez."
|
||||
live_youtube_mltv_title: Aussi sur YouTube (Match Live TV)
|
||||
live_youtube_mltv_body: "Avec Premium Light, le direct est aussi visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site."
|
||||
live_youtube_club_title: Chaîne YouTube du club
|
||||
live_youtube_club_body: "Avec Premium Full, vous pouvez diffuser le même direct sur la chaîne YouTube officielle du club."
|
||||
live_website_title: Sur le site du club
|
||||
live_website_body: "Intégration pour intégrer le lecteur sur le site du club — bientôt disponible avec Premium Full."
|
||||
cta_signup: Inscrire l'équipe
|
||||
meta_description: "Diffusez les matchs du club depuis un smartphone avec des comptes personnels, le score via un lien, le direct sur Match Live TV et YouTube, plus le replay et l'archive. Sans matériel compliqué."
|
||||
hero_title: "Les directs de votre club. Sans mots de passe partagés, sans matériel compliqué."
|
||||
hero_lead: "Invitez entraîneurs, dirigeants et parents à diffuser avec leur propre compte. Un smartphone suffit : Match Live TV gère les accès, le score, le direct, le replay et la publication."
|
||||
hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
|
||||
flow_aria: Déroulement de la diffusion
|
||||
flow_phone: Un smartphone
|
||||
flow_account: Un compte personnel
|
||||
flow_live: Vous êtes en direct
|
||||
how_title: De la salle aux spectateurs en quelques minutes
|
||||
how_1_title: Créez le match
|
||||
how_1_body: Le club prépare le match en indiquant l'équipe, l'adversaire, la date et l'heure.
|
||||
how_2_title: Ouvrez l'application
|
||||
how_2_body: La personne désignée se connecte à l'application Match Live TV avec son propre compte personnel.
|
||||
how_3_title: Lancez le direct
|
||||
how_3_body: Un smartphone suffit. Pas de matériel dédié ni de configuration compliquée.
|
||||
how_4_title: Gérez le score
|
||||
how_4_body: Une autre personne peut mettre à jour le résultat et le score via un simple lien, sans installer d'application ni créer de compte.
|
||||
how_5_title: Replay
|
||||
how_5_body: À la fin du direct, le match peut être conservé et rendu disponible en replay, selon l'offre choisie.
|
||||
accounts_title: Chacun avec son propre compte. Le mot de passe du club reste au club.
|
||||
accounts_lead: Plus besoin de partager l'identifiant et le mot de passe du compte principal. Dirigeants, entraîneurs et parents désignés reçoivent un accès personnel et révocable.
|
||||
accounts_invite_title: Invitez
|
||||
accounts_invite_body: Le club invite les entraîneurs, dirigeants ou parents chargés de la diffusion.
|
||||
accounts_transmit_title: Diffusez
|
||||
accounts_transmit_body: Chacun se connecte à l'application avec ses propres identifiants.
|
||||
accounts_revoke_title: Révoquez
|
||||
accounts_revoke_body: Quand une personne ne collabore plus avec l'équipe ou le club, son accès peut être révoqué sans changer le mot de passe des autres.
|
||||
accounts_highlight: Pas de mots de passe partagés. Chaque personne désignée a son propre accès personnel.
|
||||
score_title: Qui filme, filme. Qui tient le score, tient le score.
|
||||
score_lead: La personne qui diffuse peut se concentrer sur le cadrage. Une autre, même depuis les tribunes, peut ouvrir un simple lien et mettre à jour le score en temps réel.
|
||||
score_item_app: Aucune application à installer
|
||||
score_item_signup: Aucune inscription
|
||||
score_item_password: Aucun mot de passe
|
||||
score_item_link: Il suffit d'ouvrir le lien partagé
|
||||
score_card_aria: Accès à la régie du score
|
||||
score_card_title: Régie via un lien
|
||||
score_card_body: Celui qui met à jour sets et points ouvre un lien partagé. Aucun compte, aucune invitation.
|
||||
access_link: Accès par lien
|
||||
distribute_title: Un match, plusieurs façons de le suivre
|
||||
distribute_lead: Depuis le smartphone en salle, le direct arrive aux spectateurs sur Match Live TV et, selon l'offre, aussi sur YouTube.
|
||||
distribute_aria: Où va le direct
|
||||
distribute_phone: Smartphone
|
||||
distribute_mltv: Match Live TV
|
||||
distribute_viewers: Spectateurs
|
||||
distribute_youtube: YouTube
|
||||
youtube_badge: Premium Full
|
||||
youtube_title: Le direct reste celui de votre club
|
||||
youtube_body: Avec Premium Full, vous pouvez aussi diffuser sur la chaîne YouTube officielle du club. Le club conserve ainsi son public, ses abonnés, ses vues, l'archive des matchs et la visibilité des sponsors.
|
||||
youtube_mock_channel: Chaîne officielle du club
|
||||
youtube_mock_subs: Abonnés et vues du club
|
||||
replay_title: Le match se termine. Le souvenir reste.
|
||||
replay_lead: Sur les offres qui incluent le replay, les matchs restent enregistrés sur les serveurs Match Live TV et peuvent être revus ensuite. La durée de conservation dépend de l'offre.
|
||||
replay_item_server: Conservation sur les serveurs Match Live TV
|
||||
replay_item_plan: Durée d'archive selon l'offre
|
||||
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
|
||||
replay_card_title: Archive des matchs
|
||||
replay_card_body: Revoyez les matchs après le coup de sifflet final et téléchargez-les quand vous en avez besoin.
|
||||
replay_mock_1_title: Rossi vs Neri
|
||||
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||
replay_mock_2_title: Rossi vs Blu
|
||||
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||
replay_mock_3_title: Rossi vs Bianchi
|
||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||
more_title: Cela fait aussi partie du produit
|
||||
more_stable_title: Direct depuis le smartphone
|
||||
more_stable_body: Posez le téléphone et lancez la diffusion. Même avec un 4G faible, le direct reste aussi stable que possible, sans câbles ni mixeur.
|
||||
more_link_title: Lien pour familles et supporters
|
||||
more_link_body: "Partagez un lien : parents, grands-parents et amis regardent depuis le navigateur du téléphone ou de l'ordinateur, sans installer d'application."
|
||||
more_yt_mltv_title: Aussi sur YouTube Match Live TV
|
||||
more_yt_mltv_body: Vous pouvez aussi rendre le direct visible sur la chaîne YouTube Match Live TV, en plus du lien sur le site.
|
||||
cta_title: Prêts pour le prochain match ?
|
||||
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
|
||||
cta_primary: Commencer gratuitement
|
||||
cta_secondary: Comparer les offres
|
||||
pricing:
|
||||
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV"
|
||||
meta_description: "Offres Free, Premium Light et Premium Full pour le direct et l'archive des matchs. Abonnement annuel pour les clubs : plus de staff, plus de matchs en parallèle, replay et YouTube."
|
||||
@@ -35,8 +83,8 @@ fr:
|
||||
club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}"
|
||||
manage_subscription: Gérer l'abonnement
|
||||
team_title: Offres pour votre équipe
|
||||
team_lead: "Abonnement annuel pour les clubs : les responsables de diffusion démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
|
||||
table_staff: Responsables de diffusion / an
|
||||
team_lead: "Abonnement annuel pour les clubs : les comptes diffuseurs démarrent le direct depuis l'application ; le score se gère avec un lien à partager."
|
||||
table_staff: Comptes diffuseurs
|
||||
table_matches: Matchs simultanés
|
||||
table_live_mltv: Direct sur Match Live TV
|
||||
table_youtube: YouTube
|
||||
@@ -44,8 +92,7 @@ fr:
|
||||
table_download: Téléchargement téléphone
|
||||
table_price: Prix
|
||||
table_price_free: "€0"
|
||||
table_price_light_html: "€40/an ou €5/mois"
|
||||
table_price_full_html: "€200/an ou €20/mois"
|
||||
table_price_note: "tarif %{list} — %{monthly}"
|
||||
table_yes: Oui
|
||||
table_no: Non
|
||||
table_unlimited: Illimité
|
||||
@@ -55,8 +102,16 @@ fr:
|
||||
contact_button: Contactez-nous
|
||||
plans:
|
||||
price_alt_html: "ou <strong>%{price}</strong>"
|
||||
staff_html: "Responsables de diffusion : <strong>%{count}</strong> / an par équipe"
|
||||
staff_unlimited: illimité
|
||||
price_equiv: "soit %{price}"
|
||||
launch_price: Prix de lancement
|
||||
recommended_badge: Conseillé pour les clubs
|
||||
cta_name:
|
||||
premium_light: Light
|
||||
premium_full: Full
|
||||
staff_html: "Comptes diffuseurs : maximum <strong>%{count}</strong> par abonnement"
|
||||
staff_unlimited_html: "Comptes diffuseurs : <strong>illimités</strong> par abonnement"
|
||||
staff_unlimited: illimités
|
||||
staff_note_full: "Pas de mot de passe partagé : chaque personne utilise son propre accès, révocable à tout moment."
|
||||
matches_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)"
|
||||
matches_unlimited: illimités
|
||||
replay_html: "Replay : %{value}"
|
||||
|
||||
@@ -2,32 +2,80 @@ it:
|
||||
pages:
|
||||
features:
|
||||
meta_title: "Funzionalità streaming partite giovanili — Match Live TV"
|
||||
meta_description: "App per diretta live da telefono: filma la partita, aggiorna il punteggio, condividi il link con genitori e nonni. Archivio gare e piani per società sportive giovanili."
|
||||
title: Funzionalità
|
||||
lead: "Tutto ciò che serve per far seguire le partite a chi non può essere in palestra: semplice per coach e volontari, comodo per genitori, nonni e parenti."
|
||||
staff_section_title: Per la società e lo staff
|
||||
staff_invite_title: Inviti con la propria email
|
||||
staff_invite_body: "Il presidente o l'owner invita chi filma e chi aggiorna il punteggio: ognuno accede con la sua email, senza condividere password."
|
||||
staff_access_title: Gestione accessi
|
||||
staff_access_body: Puoi togliere l'accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra.
|
||||
staff_plans_title: Piani su misura
|
||||
staff_plans_body: Con Free, Premium Light o Premium Full aumenti quante persone possono aiutare in diretta e quante partite possono andare in parallelo.
|
||||
live_section_title: In campo e in diretta
|
||||
live_phone_title: Filma dal telefono
|
||||
live_phone_body: "Appoggi il telefono e avvii la diretta: anche con il 4G debole la trasmissione resta stabile il più possibile, senza impazzire con cavi o mixer."
|
||||
live_score_title: Punteggio da un secondo telefono
|
||||
live_score_body: "Un altro volontario aggiorna set e punti da un secondo cellulare: basta inquadrare il codice che compare sullo schermo di chi filma."
|
||||
live_link_title: Link per famiglie e tifosi
|
||||
live_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
|
||||
live_archive_title: Archivio partite
|
||||
live_archive_body: "Con Premium Light o Full le gare restano salvate sul server (30 o 90 giorni) e puoi scaricarle sul telefono quando serve."
|
||||
live_youtube_mltv_title: Anche su YouTube (Match Live TV)
|
||||
live_youtube_mltv_body: Con Premium Light la diretta è visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
|
||||
live_youtube_club_title: Canale YouTube della società
|
||||
live_youtube_club_body: Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club.
|
||||
live_website_title: Sul sito della società
|
||||
live_website_body: Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full.
|
||||
cta_signup: Registra la squadra
|
||||
meta_description: "Trasmetti le partite della società dallo smartphone con account personali, punteggio via link, diretta su Match Live TV e YouTube, replay e archivio. Senza attrezzatura complicata."
|
||||
hero_title: "Le dirette della tua società. Senza password condivise, senza attrezzatura complicata."
|
||||
hero_lead: "Invita allenatori, dirigenti e genitori a trasmettere con il proprio account. Basta uno smartphone: Match Live TV gestisce accessi, punteggio, diretta, replay e pubblicazione."
|
||||
hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
|
||||
flow_aria: Flusso della trasmissione
|
||||
flow_phone: Uno smartphone
|
||||
flow_account: Un account personale
|
||||
flow_live: Sei in diretta
|
||||
how_title: Dalla palestra agli spettatori in pochi minuti
|
||||
how_1_title: Crea la partita
|
||||
how_1_body: La società prepara la partita indicando squadra, avversario, data e ora.
|
||||
how_2_title: Apri l'app
|
||||
how_2_body: L'incaricato accede dall'app Match Live TV con il proprio account personale.
|
||||
how_3_title: Inizia la diretta
|
||||
how_3_body: Basta lo smartphone. Nessuna attrezzatura dedicata o configurazione complicata.
|
||||
how_4_title: Gestisci il punteggio
|
||||
how_4_body: Un'altra persona può aggiornare risultato e punteggio tramite un semplice link, senza installare app e senza creare un account.
|
||||
how_5_title: Replay
|
||||
how_5_body: Al termine della diretta la partita può essere conservata e resa disponibile come replay, in base al piano scelto.
|
||||
accounts_title: Ognuno con il proprio account. La password della società resta alla società.
|
||||
accounts_lead: Non serve più condividere username e password dell'account principale. Dirigenti, allenatori e genitori incaricati ricevono un accesso personale e revocabile.
|
||||
accounts_invite_title: Invita
|
||||
accounts_invite_body: La società invita allenatori, dirigenti o genitori incaricati della trasmissione.
|
||||
accounts_transmit_title: Trasmetti
|
||||
accounts_transmit_body: Ognuno accede all'app con le proprie credenziali personali.
|
||||
accounts_revoke_title: Revoca
|
||||
accounts_revoke_body: Quando una persona non collabora più con la squadra o con la società, il suo accesso può essere revocato senza cambiare la password agli altri.
|
||||
accounts_highlight: Niente password condivise. Ogni incaricato ha il proprio accesso personale.
|
||||
score_title: Chi filma, filma. Chi tiene il punteggio, tiene il punteggio.
|
||||
score_lead: La persona che trasmette può concentrarsi sull'inquadratura. Un'altra persona, anche dagli spalti, può aprire un semplice link e aggiornare il punteggio in tempo reale.
|
||||
score_item_app: Nessuna app da installare
|
||||
score_item_signup: Nessuna registrazione
|
||||
score_item_password: Nessuna password
|
||||
score_item_link: Basta aprire il link condiviso
|
||||
score_card_aria: Accesso alla regia
|
||||
score_card_title: Regia con un link
|
||||
score_card_body: Chi aggiorna set e punti apre un link condiviso. Nessun account e nessun invito.
|
||||
access_link: Accesso con link
|
||||
distribute_title: Una partita, più modi di seguirla
|
||||
distribute_lead: Dallo smartphone in palestra la diretta arriva agli spettatori su Match Live TV e, quando previsto dal piano, anche su YouTube.
|
||||
distribute_aria: Dove arriva la diretta
|
||||
distribute_phone: Smartphone
|
||||
distribute_mltv: Match Live TV
|
||||
distribute_viewers: Spettatori
|
||||
distribute_youtube: YouTube
|
||||
youtube_badge: Premium Full
|
||||
youtube_title: La diretta resta della tua società
|
||||
youtube_body: Con Premium Full puoi trasmettere anche sul canale YouTube ufficiale del club. La società mantiene così il proprio pubblico, gli iscritti, le visualizzazioni, l'archivio delle partite e la visibilità degli sponsor.
|
||||
youtube_mock_channel: Canale ufficiale società
|
||||
youtube_mock_subs: Iscritti e visualizzazioni del club
|
||||
replay_title: La partita finisce. Il ricordo resta.
|
||||
replay_lead: Nei piani che prevedono il replay, le gare restano salvate sui server Match Live TV e possono essere riviste in seguito. La durata della conservazione dipende dal piano.
|
||||
replay_item_server: Conservazione sui server Match Live TV
|
||||
replay_item_plan: Durata dell'archivio in base al piano
|
||||
replay_item_download: Download del video sul telefono quando serve
|
||||
replay_card_title: Archivio partite
|
||||
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
|
||||
replay_mock_1_title: Rossi vs Neri
|
||||
replay_mock_1_meta: "12/05 · 01:25:34"
|
||||
replay_mock_2_title: Rossi vs Blu
|
||||
replay_mock_2_meta: "05/05 · 01:18:22"
|
||||
replay_mock_3_title: Rossi vs Bianchi
|
||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||
more_title: Anche questo fa parte del prodotto
|
||||
more_stable_title: Diretta dallo smartphone
|
||||
more_stable_body: Appoggi il telefono e avvii la trasmissione. Anche con il 4G debole la diretta resta stabile il più possibile, senza cavi o mixer.
|
||||
more_link_title: Link per famiglie e tifosi
|
||||
more_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app."
|
||||
more_yt_mltv_title: Anche su YouTube Match Live TV
|
||||
more_yt_mltv_body: Puoi rendere la diretta visibile anche sul canale YouTube Match Live TV, oltre al link sul sito.
|
||||
cta_title: Pronti per la prossima partita?
|
||||
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
|
||||
cta_primary: Inizia gratis
|
||||
cta_secondary: Confronta i piani
|
||||
pricing:
|
||||
meta_title: "Prezzi streaming partite giovanili — Match Live TV"
|
||||
meta_description: "Piani Free, Premium Light e Premium Full per dirette live e archivio partite. Abbonamento annuale per società: più staff, più partite in parallelo, replay e YouTube."
|
||||
@@ -35,8 +83,8 @@ it:
|
||||
club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}"
|
||||
manage_subscription: Gestisci abbonamento
|
||||
team_title: Piani per la tua squadra
|
||||
team_lead: "Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
|
||||
table_staff: Responsabili trasmissione / anno
|
||||
team_lead: "Abbonamento annuale per società: gli account trasmettitori avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
|
||||
table_staff: Account trasmettitori
|
||||
table_matches: Partite in contemporanea
|
||||
table_live_mltv: Live su Match Live TV
|
||||
table_youtube: YouTube
|
||||
@@ -44,8 +92,7 @@ it:
|
||||
table_download: Download telefono
|
||||
table_price: Prezzo
|
||||
table_price_free: "€0"
|
||||
table_price_light_html: "€40/anno o €5/mese"
|
||||
table_price_full_html: "€200/anno o €20/mese"
|
||||
table_price_note: "listino %{list} — %{monthly}"
|
||||
table_yes: "Sì"
|
||||
table_no: "No"
|
||||
table_unlimited: Illimitato
|
||||
@@ -55,8 +102,16 @@ it:
|
||||
contact_button: Contattaci
|
||||
plans:
|
||||
price_alt_html: "oppure <strong>%{price}</strong>"
|
||||
staff_html: "Responsabili trasmissione: <strong>%{count}</strong> / anno per squadra"
|
||||
staff_unlimited: illimitato
|
||||
price_equiv: "equivale a %{price}"
|
||||
launch_price: Prezzo lancio
|
||||
recommended_badge: Consigliato per le società
|
||||
cta_name:
|
||||
premium_light: Light
|
||||
premium_full: Full
|
||||
staff_html: "Account trasmettitori: massimo <strong>%{count}</strong> per abbonamento"
|
||||
staff_unlimited_html: "Account trasmettitori: <strong>illimitati</strong> per abbonamento"
|
||||
staff_unlimited: illimitati
|
||||
staff_note_full: "Niente password condivise: ogni incaricato utilizza il proprio accesso personale, revocabile in qualsiasi momento."
|
||||
matches_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)"
|
||||
matches_unlimited: illimitate
|
||||
replay_html: "Replay: %{value}"
|
||||
|
||||
@@ -16,6 +16,7 @@ de:
|
||||
my_team: Mein Team
|
||||
login: Anmelden
|
||||
logout: Abmelden
|
||||
account: Konto
|
||||
signup: Team registrieren
|
||||
footer:
|
||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||
@@ -42,6 +43,13 @@ de:
|
||||
tagline: "Streamen Sie live vom Smartphone. Großeltern, entfernte Verwandte, Freunde — schauen im Browser, ohne Installation. Und wenn Sie es verpasst haben: es ist im Archiv."
|
||||
cta_signup: Team registrieren
|
||||
cta_live: Live-Spiele ansehen
|
||||
stores_label: Live gehen vom Smartphone
|
||||
store_app_store_kicker: Laden im
|
||||
store_app_store_name: App Store
|
||||
store_app_store_aria: Match Live TV im App Store laden
|
||||
store_play_kicker: Jetzt bei
|
||||
store_play_name: Google Play
|
||||
store_play_aria: Match Live TV bei Google Play laden
|
||||
features_aria: Wichtige Vorteile
|
||||
feature_stable: Stabile Streams
|
||||
feature_archive: Sicheres Archiv
|
||||
@@ -102,7 +110,7 @@ de:
|
||||
password_reset:
|
||||
meta_title: "Neues Passwort — Match Live TV"
|
||||
title: Neues Passwort wählen
|
||||
new_password_label: "Neues Passwort (min. 8 Zeichen)"
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
submit: Passwort speichern
|
||||
back_to_login: Zurück zur Anmeldung
|
||||
invitation:
|
||||
@@ -118,8 +126,21 @@ de:
|
||||
signup_link: registrieren Sie sich
|
||||
mobile_app_label: "Mobile App:"
|
||||
open_in_app: Einladung in der App öffnen
|
||||
account:
|
||||
meta_title: "Dein Konto — Match Live TV"
|
||||
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
|
||||
title: Dein Konto
|
||||
profile_heading: Profil
|
||||
password_heading: Passwort ändern
|
||||
name_label: Name
|
||||
role_label: "Rolle: %{role}"
|
||||
current_password_label: Aktuelles Passwort
|
||||
new_password_label: "Neues Passwort (mind. 8, mindestens 3 Zeichenarten)"
|
||||
save_profile: Profil speichern
|
||||
save_password: Passwort aktualisieren
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
support: Support
|
||||
cookies: Cookies
|
||||
terms: AGB
|
||||
pricing: Preise
|
||||
|
||||
@@ -16,6 +16,7 @@ en:
|
||||
my_team: My team
|
||||
login: Log in
|
||||
logout: Log out
|
||||
account: Account
|
||||
signup: Register a team
|
||||
footer:
|
||||
tagline: Every match, every event, for those who can't be there
|
||||
@@ -42,6 +43,13 @@ en:
|
||||
tagline: Stream live from your smartphone. Grandparents, distant relatives, friends — they watch in the browser, with nothing to install. And if you missed it, it's in the archive.
|
||||
cta_signup: Register your team
|
||||
cta_live: Watch live matches
|
||||
stores_label: To go live from your smartphone
|
||||
store_app_store_kicker: Download on the
|
||||
store_app_store_name: App Store
|
||||
store_app_store_aria: Download Match Live TV on the App Store
|
||||
store_play_kicker: Get it on
|
||||
store_play_name: Google Play
|
||||
store_play_aria: Get Match Live TV on Google Play
|
||||
features_aria: Key benefits
|
||||
feature_stable: Reliable streams
|
||||
feature_archive: Secure archive
|
||||
@@ -102,7 +110,7 @@ en:
|
||||
password_reset:
|
||||
meta_title: "New password — Match Live TV"
|
||||
title: Choose a new password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
submit: Save password
|
||||
back_to_login: Back to login
|
||||
invitation:
|
||||
@@ -118,8 +126,21 @@ en:
|
||||
signup_link: sign up
|
||||
mobile_app_label: "Mobile app:"
|
||||
open_in_app: Open invitation in the app
|
||||
account:
|
||||
meta_title: "Your account — Match Live TV"
|
||||
meta_description: Manage your Match Live TV account name and password.
|
||||
title: Your account
|
||||
profile_heading: Profile
|
||||
password_heading: Change password
|
||||
name_label: Name
|
||||
role_label: "Role: %{role}"
|
||||
current_password_label: Current password
|
||||
new_password_label: "New password (min. 8, at least 3 character types)"
|
||||
save_profile: Save profile
|
||||
save_password: Update password
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Support
|
||||
cookies: Cookies
|
||||
terms: Terms
|
||||
pricing: Pricing
|
||||
|
||||
@@ -16,6 +16,7 @@ es:
|
||||
my_team: Mi equipo
|
||||
login: Acceder
|
||||
logout: Salir
|
||||
account: Cuenta
|
||||
signup: Registrar equipo
|
||||
footer:
|
||||
tagline: Cada partido, cada evento, para quien no puede estar
|
||||
@@ -42,6 +43,13 @@ es:
|
||||
tagline: Emite en directo desde el smartphone. Abuelos, familiares lejanos, amigos — miran en el navegador, sin instalar nada. Y si te lo perdiste, está en el archivo.
|
||||
cta_signup: Registra tu equipo
|
||||
cta_live: Ver directos
|
||||
stores_label: Para emitir en directo desde el smartphone
|
||||
store_app_store_kicker: Consíguelo en
|
||||
store_app_store_name: App Store
|
||||
store_app_store_aria: Descarga Match Live TV en App Store
|
||||
store_play_kicker: Disponible en
|
||||
store_play_name: Google Play
|
||||
store_play_aria: Descarga Match Live TV en Google Play
|
||||
features_aria: Ventajas principales
|
||||
feature_stable: Directos estables
|
||||
feature_archive: Archivo seguro
|
||||
@@ -102,7 +110,7 @@ es:
|
||||
password_reset:
|
||||
meta_title: "Nueva contraseña — Match Live TV"
|
||||
title: Elige una nueva contraseña
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
submit: Guardar contraseña
|
||||
back_to_login: Volver al inicio de sesión
|
||||
invitation:
|
||||
@@ -118,8 +126,21 @@ es:
|
||||
signup_link: regístrate
|
||||
mobile_app_label: "App móvil:"
|
||||
open_in_app: Abrir invitación en la app
|
||||
account:
|
||||
meta_title: "Tu cuenta — Match Live TV"
|
||||
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
|
||||
title: Tu cuenta
|
||||
profile_heading: Perfil
|
||||
password_heading: Cambiar contraseña
|
||||
name_label: Nombre
|
||||
role_label: "Rol: %{role}"
|
||||
current_password_label: Contraseña actual
|
||||
new_password_label: "Nueva contraseña (mín. 8, al menos 3 tipos de caracteres)"
|
||||
save_profile: Guardar perfil
|
||||
save_password: Actualizar contraseña
|
||||
common:
|
||||
privacy: Privacidad
|
||||
support: Soporte
|
||||
cookies: Cookies
|
||||
terms: Términos
|
||||
pricing: Precios
|
||||
|
||||
@@ -16,6 +16,7 @@ fr:
|
||||
my_team: Mon équipe
|
||||
login: Connexion
|
||||
logout: Déconnexion
|
||||
account: Compte
|
||||
signup: Inscrire une équipe
|
||||
footer:
|
||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||
@@ -42,6 +43,13 @@ fr:
|
||||
tagline: Diffusez en direct depuis votre smartphone. Les grands-parents, la famille éloignée, les amis — regardent dans le navigateur, sans rien installer. Et si vous l'avez manqué, c'est dans l'archive.
|
||||
cta_signup: Inscrire votre équipe
|
||||
cta_live: Voir les directs
|
||||
stores_label: Pour passer en direct depuis le smartphone
|
||||
store_app_store_kicker: Télécharger dans
|
||||
store_app_store_name: App Store
|
||||
store_app_store_aria: Télécharger Match Live TV dans l'App Store
|
||||
store_play_kicker: Disponible sur
|
||||
store_play_name: Google Play
|
||||
store_play_aria: Télécharger Match Live TV sur Google Play
|
||||
features_aria: Avantages principaux
|
||||
feature_stable: Directs stables
|
||||
feature_archive: Archive sécurisée
|
||||
@@ -102,7 +110,7 @@ fr:
|
||||
password_reset:
|
||||
meta_title: "Nouveau mot de passe — Match Live TV"
|
||||
title: Choisissez un nouveau mot de passe
|
||||
new_password_label: "Nouveau mot de passe (8 caractères min.)"
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
submit: Enregistrer le mot de passe
|
||||
back_to_login: Retour à la connexion
|
||||
invitation:
|
||||
@@ -118,8 +126,21 @@ fr:
|
||||
signup_link: inscrivez-vous
|
||||
mobile_app_label: "Application mobile :"
|
||||
open_in_app: Ouvrir l'invitation dans l'application
|
||||
account:
|
||||
meta_title: "Votre compte — Match Live TV"
|
||||
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
|
||||
title: Votre compte
|
||||
profile_heading: Profil
|
||||
password_heading: Changer le mot de passe
|
||||
name_label: Nom
|
||||
role_label: "Rôle : %{role}"
|
||||
current_password_label: Mot de passe actuel
|
||||
new_password_label: "Nouveau mot de passe (min. 8, au moins 3 types de caractères)"
|
||||
save_profile: Enregistrer le profil
|
||||
save_password: Mettre à jour le mot de passe
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
support: Support
|
||||
cookies: Cookies
|
||||
terms: Conditions
|
||||
pricing: Tarifs
|
||||
|
||||
@@ -16,6 +16,7 @@ it:
|
||||
my_team: La mia squadra
|
||||
login: Accedi
|
||||
logout: Esci
|
||||
account: Account
|
||||
signup: Registra squadra
|
||||
footer:
|
||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||
@@ -42,6 +43,13 @@ it:
|
||||
tagline: Trasmetti in diretta dallo smartphone. I nonni, i parenti lontani, gli amici — guardano dal browser, senza installare nulla. E se te la sei persa, la ritrovi nell'archivio.
|
||||
cta_signup: Registra la tua squadra
|
||||
cta_live: Guarda le dirette
|
||||
stores_label: Per andare in diretta dallo smartphone
|
||||
store_app_store_kicker: Scarica su
|
||||
store_app_store_name: App Store
|
||||
store_app_store_aria: Scarica Match Live TV su App Store
|
||||
store_play_kicker: Disponibile su
|
||||
store_play_name: Google Play
|
||||
store_play_aria: Scarica Match Live TV su Google Play
|
||||
features_aria: Vantaggi principali
|
||||
feature_stable: Dirette stabili
|
||||
feature_archive: Archivio sicuro
|
||||
@@ -102,7 +110,7 @@ it:
|
||||
password_reset:
|
||||
meta_title: "Nuova password — Match Live TV"
|
||||
title: Scegli una nuova password
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
submit: Salva password
|
||||
back_to_login: Torna al login
|
||||
invitation:
|
||||
@@ -118,8 +126,21 @@ it:
|
||||
signup_link: registrati
|
||||
mobile_app_label: "App mobile:"
|
||||
open_in_app: Apri invito nell'app
|
||||
account:
|
||||
meta_title: "Il tuo account — Match Live TV"
|
||||
meta_description: Gestisci nome e password del tuo account Match Live TV.
|
||||
title: Il tuo account
|
||||
profile_heading: Profilo
|
||||
password_heading: Cambia password
|
||||
name_label: Nome
|
||||
role_label: "Ruolo: %{role}"
|
||||
current_password_label: Password attuale
|
||||
new_password_label: "Nuova password (min. 8, almeno 3 tipi di caratteri)"
|
||||
save_profile: Salva profilo
|
||||
save_password: Aggiorna password
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Supporto
|
||||
cookies: Cookie
|
||||
terms: Termini
|
||||
pricing: Prezzi
|
||||
|
||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
||||
post "auth/logout", to: "auth#logout"
|
||||
post "auth/refresh", to: "auth#refresh"
|
||||
get "auth/me", to: "auth#me"
|
||||
post "auth/password/forgot", to: "auth#forgot_password"
|
||||
get "account", to: "accounts#show"
|
||||
patch "account", to: "accounts#update"
|
||||
patch "account/password", to: "accounts#password"
|
||||
get "sports", to: "sports#index"
|
||||
|
||||
get "invitations/:token", to: "invitations#show"
|
||||
@@ -150,6 +154,7 @@ Rails.application.routes.draw do
|
||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||
get "pricing", to: redirect("/prezzi")
|
||||
get "privacy", to: "pages#privacy", as: :privacy
|
||||
get "support", to: "pages#support", as: :support
|
||||
get "cookie", to: "pages#cookies", as: :cookies
|
||||
get "cookies", to: redirect("/cookie")
|
||||
get "termini", to: "pages#terms", as: :termini
|
||||
@@ -163,6 +168,10 @@ Rails.application.routes.draw do
|
||||
post "password/forgot", to: "password_resets#create"
|
||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||
patch "password/reset", to: "password_resets#update"
|
||||
get "account", to: "accounts#show", as: :account
|
||||
patch "account", to: "accounts#update"
|
||||
get "account/password", to: redirect("/account"), as: nil
|
||||
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||
get "clubs/new", to: "clubs#new", as: :new_club
|
||||
post "clubs", to: "clubs#create"
|
||||
get "clubs/:id", to: "clubs#show", as: :club
|
||||
|
||||
+4
-4
@@ -1,18 +1,18 @@
|
||||
load Rails.root.join("db/seeds/plans.rb")
|
||||
|
||||
AdminAccount.find_or_create_by!(username: "admin") do |a|
|
||||
a.password = "admin"
|
||||
a.password = "AdminPass123"
|
||||
end
|
||||
|
||||
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||
u.name = "Coach Demo"
|
||||
u.password = "password123"
|
||||
u.password = "Password123"
|
||||
u.role = "coach"
|
||||
end
|
||||
|
||||
admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
||||
u.name = "Admin"
|
||||
u.password = "password123"
|
||||
u.password = "Password123"
|
||||
u.role = "admin"
|
||||
end
|
||||
|
||||
@@ -41,5 +41,5 @@ match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m
|
||||
m.phase = "Semifinale"
|
||||
end
|
||||
|
||||
puts "Seed OK: coach@matchlivetv.test / password123"
|
||||
puts "Seed OK: coach@matchlivetv.test / Password123"
|
||||
puts "Club: #{club.name}, Team: #{team.name}, Match: #{match.opponent_name}"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
[
|
||||
{
|
||||
"relation": [
|
||||
"delegate_permission/common.handle_all_urls",
|
||||
"delegate_permission/common.get_login_creds"
|
||||
],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.matchlivetv.match_live_tv",
|
||||
"sha256_cert_fingerprints": [
|
||||
"C3:F0:89:51:0B:00:3A:FE:10:BD:ED:68:45:1B:4F:1D:B8:5A:63:EE:8A:DA:F7:2F:5A:D6:1D:97:C9:A3:95:47",
|
||||
"09:69:25:CD:8B:EC:BA:75:9A:5E:49:32:E1:35:BD:F1:AF:1E:5A:29:93:E2:65:85:8A:41:E2:11:DA:64:97:F0"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" role="img" aria-hidden="true">
|
||||
<path fill="#ffffff" d="M18.71 19.5c-.83 1.24-1.71 2.45-3.05 2.47-1.34.03-1.77-.79-3.29-.79-1.53 0-2 .77-3.27.82-1.31.05-2.3-1.32-3.14-2.53C4.25 17 2.94 12.45 4.7 9.39c.87-1.52 2.43-2.48 4.12-2.51 1.28-.02 2.5.87 3.29.87.78 0 2.26-1.07 3.81-.91.65.03 2.47.26 3.64 1.98-.09.06-2.17 1.28-2.15 3.81.03 3.02 2.65 4.03 2.68 4.04-.03.07-.42 1.44-1.38 2.83M13 3.5c.73-.83 1.94-1.46 2.94-1.5.13 1.17-.34 2.35-1.04 3.19-.69.85-1.83 1.51-2.95 1.42-.15-1.15.41-2.35 1.05-3.11z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 283" role="img" aria-hidden="true">
|
||||
<path fill="#EA4335" d="M119.55 134.92 1.06 259.06c2.7 9.56 9.66 17.33 18.86 21.06 9.2 3.73 19.61 2.99 28.2-1.99l133.33-75.93-61.9-67.28z"/>
|
||||
<path fill="#FBBC04" d="M239.37 113.81 181.71 80.79l-64.9 56.95 65.16 64.28 57.22-32.67c10.33-5.41 16.81-16.11 16.81-27.77s-6.48-22.36-16.81-27.77z"/>
|
||||
<path fill="#4285F4" d="M1.06 23.49C.34 26.13-.01 28.87 0 31.61v219.33c.01 2.74.36 5.47 1.06 8.12L123.61 138.1 1.06 23.49z"/>
|
||||
<path fill="#34A853" d="m120.44 141.27 61.28-60.48L48.56 4.5C43.55 1.57 37.86.02 32.05 0 17.64-.03 4.98 9.53 1.06 23.4l119.38 117.87z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 663 B |
+1139
-23
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,7 @@ RSpec.describe Public::BillingHelper, type: :helper do
|
||||
current_interval: "yearly"
|
||||
)
|
||||
expect(action[:kind]).to eq(:current)
|
||||
expect(action[:label]).to include("€40/anno")
|
||||
expect(action[:label]).to include("€59/anno")
|
||||
end
|
||||
|
||||
it "propone cambio intervallo sullo stesso piano" do
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe PasswordComplexity do
|
||||
describe ".violation" do
|
||||
it "accepts a password with 3 character classes" do
|
||||
expect(described_class.violation("NewPass123")).to be_nil
|
||||
end
|
||||
|
||||
it "accepts symbols instead of one letter class" do
|
||||
expect(described_class.violation("newpass1!")).to be_nil
|
||||
end
|
||||
|
||||
it "rejects passwords that are too short" do
|
||||
expect(described_class.violation("Ab1!")).to eq(:too_short)
|
||||
end
|
||||
|
||||
it "rejects passwords with fewer than 3 classes" do
|
||||
expect(described_class.violation("password123")).to eq(:too_weak)
|
||||
expect(described_class.violation("PASSWORD123")).to eq(:too_weak)
|
||||
expect(described_class.violation("Password")).to eq(:too_weak)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".same_as_current?" do
|
||||
let!(:user) { User.create!(email: "same@example.com", name: "Same", password: "Password123", role: "coach") }
|
||||
|
||||
it "detects when the new password matches the current one" do
|
||||
expect(described_class.same_as_current?(user, "Password123")).to eq(true)
|
||||
expect(described_class.same_as_current?(user, "OtherPass123")).to eq(false)
|
||||
end
|
||||
end
|
||||
|
||||
describe "User validation" do
|
||||
it "blocks weak passwords on create" do
|
||||
user = User.new(email: "weak@example.com", name: "Weak", password: "password123", role: "coach")
|
||||
expect(user).not_to be_valid
|
||||
expect(user.errors[:password]).to be_present
|
||||
end
|
||||
|
||||
it "allows strong passwords on create" do
|
||||
user = User.new(email: "strong@example.com", name: "Strong", password: "NewPass123", role: "coach")
|
||||
expect(user).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,127 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Account API", type: :request do
|
||||
let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "Password123", role: "coach") }
|
||||
let(:auth_headers) do
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||
token = JSON.parse(response.body).fetch("access_token")
|
||||
{ "Authorization" => "Bearer #{token}" }
|
||||
end
|
||||
|
||||
describe "GET /api/v1/account" do
|
||||
it "returns the current user profile" do
|
||||
get "/api/v1/account", headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = JSON.parse(response.body)
|
||||
expect(body).to include("email" => user.email, "name" => "Account User", "role" => "coach")
|
||||
end
|
||||
|
||||
it "requires authentication" do
|
||||
get "/api/v1/account"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /api/v1/account" do
|
||||
it "updates the name" do
|
||||
patch "/api/v1/account", params: { name: "Nuovo Nome" }, headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["name"]).to eq("Nuovo Nome")
|
||||
expect(user.reload.name).to eq("Nuovo Nome")
|
||||
end
|
||||
|
||||
it "rejects a blank name" do
|
||||
patch "/api/v1/account", params: { name: " " }, headers: auth_headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /api/v1/account/password" do
|
||||
it "changes the password with the current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "NewPass123",
|
||||
password_confirmation: "NewPass123"
|
||||
},
|
||||
headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.authenticate("NewPass123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects an incorrect current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "wrong",
|
||||
password: "NewPass123",
|
||||
password_confirmation: "NewPass123"
|
||||
},
|
||||
headers: auth_headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects a password that fails complexity rules" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "newpass123",
|
||||
password_confirmation: "newpass123"
|
||||
},
|
||||
headers: auth_headers.merge("Accept-Language" => "en")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)["error"]).to match(/3 of/i)
|
||||
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects reusing the current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "Password123",
|
||||
password_confirmation: "Password123"
|
||||
},
|
||||
headers: auth_headers.merge("Accept-Language" => "en")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)["error"]).to match(/different/i)
|
||||
expect(user.reload.authenticate("Password123")).to be_truthy
|
||||
end
|
||||
|
||||
it "localizes password errors from Accept-Language" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "Password123",
|
||||
password: "Password123",
|
||||
password_confirmation: "Password123"
|
||||
},
|
||||
headers: auth_headers.merge("Accept-Language" => "it")
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(JSON.parse(response.body)["error"]).to eq("La nuova password deve essere diversa da quella attuale")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/v1/auth/password/forgot" do
|
||||
it "always returns ok and sends mail when the user exists" do
|
||||
expect {
|
||||
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||
}.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.password_reset_digest).to be_present
|
||||
end
|
||||
|
||||
it "returns the same message for unknown emails" do
|
||||
expect {
|
||||
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
|
||||
it "localizes the response message from Accept-Language" do
|
||||
post "/api/v1/auth/password/forgot",
|
||||
params: { email: user.email },
|
||||
headers: { "Accept-Language" => "fr" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["message"]).to include("réinitialiser")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,10 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Auth API", type: :request do
|
||||
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "password123", role: "coach") }
|
||||
let!(:user) { User.create!(email: "test@example.com", name: "Test", password: "Password123", role: "coach") }
|
||||
|
||||
it "logs in with valid credentials" do
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "Password123" }
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)).to have_key("access_token")
|
||||
end
|
||||
|
||||
@@ -72,7 +72,7 @@ RSpec.describe "Public club billing", type: :request do
|
||||
get public_club_billing_path(club)
|
||||
|
||||
expect(response.body).to include("Completa dati di fatturazione")
|
||||
expect(response.body).not_to include("Attiva Premium Light — €40/anno")
|
||||
expect(response.body).not_to include("Attiva Light — €59/anno")
|
||||
end
|
||||
|
||||
it "dopo profilo completo reindirizza al checkout" do
|
||||
@@ -111,8 +111,8 @@ RSpec.describe "Public club billing", type: :request do
|
||||
|
||||
login!
|
||||
get public_club_billing_path(club)
|
||||
expect(response.body).to include("Premium Light — €40/anno")
|
||||
expect(response.body).to include("Premium Light — €5/mese")
|
||||
expect(response.body).to include("Attiva Light — €59/anno")
|
||||
expect(response.body).to include("Attiva Light — €7,90/mese")
|
||||
|
||||
allow(Billing::Stripe::CheckoutSession).to receive(:new).and_return(
|
||||
instance_double(Billing::Stripe::CheckoutSession, url: "https://checkout.stripe.com/test")
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public features page", type: :request do
|
||||
it "racconta il prodotto con sezioni narrative e CTA" do
|
||||
get public_features_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Le dirette della tua società")
|
||||
expect(response.body).to include("Senza password condivise")
|
||||
expect(response.body).to include("Uno smartphone")
|
||||
expect(response.body).to include("Un account personale")
|
||||
expect(response.body).to include("Sei in diretta")
|
||||
expect(response.body).to include("Dalla palestra agli spettatori")
|
||||
expect(response.body).to include("Ognuno con il proprio account")
|
||||
expect(response.body).to include("Niente password condivise")
|
||||
expect(response.body).to include("Chi filma, filma")
|
||||
expect(response.body).to include("Accesso con link")
|
||||
expect(response.body).to include("La diretta resta della tua società")
|
||||
expect(response.body).to include("La partita finisce. Il ricordo resta.")
|
||||
expect(response.body).to include("Inizia gratis")
|
||||
expect(response.body).to include("Confronta i piani")
|
||||
expect(response.body).to include(public_signup_path)
|
||||
expect(response.body).to include(public_prezzi_path)
|
||||
expect(response.body).not_to include("translation missing")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public home page", type: :request do
|
||||
it "mostra i link agli store nell'hero e nel footer" do
|
||||
get root_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(MatchLiveTv.app_store_url)
|
||||
expect(response.body).to include(MatchLiveTv.play_store_url)
|
||||
expect(response.body).to include(I18n.t("home.stores_label", locale: :it))
|
||||
expect(response.body).to include(I18n.t("home.store_app_store_name", locale: :it))
|
||||
expect(response.body).to include(I18n.t("home.store_play_name", locale: :it))
|
||||
expect(response.body.scan(MatchLiveTv.app_store_url).size).to eq(3)
|
||||
expect(response.body.scan(MatchLiveTv.play_store_url).size).to eq(3)
|
||||
end
|
||||
|
||||
{
|
||||
"it" => "Per andare in diretta dallo smartphone",
|
||||
"en" => "To go live from your smartphone",
|
||||
"fr" => "Pour passer en direct depuis le smartphone",
|
||||
"de" => "Live gehen vom Smartphone",
|
||||
"es" => "Para emitir en directo desde el smartphone"
|
||||
}.each do |locale, label|
|
||||
it "traduce i badge store in #{locale} senza translation missing" do
|
||||
cookies[:mltv_locale] = locale
|
||||
get root_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(label)
|
||||
expect(response.body).not_to include("translation missing")
|
||||
end
|
||||
end
|
||||
|
||||
it "non mostra i badge store nel chrome App Store Review" do
|
||||
get public_support_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).not_to include(MatchLiveTv.app_store_url)
|
||||
expect(response.body).not_to include(MatchLiveTv.play_store_url)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public pricing page", type: :request do
|
||||
before { load Rails.root.join("db/seeds/plans.rb") }
|
||||
|
||||
it "mostra prezzi lancio, listino barrato e Full in evidenza" do
|
||||
get public_prezzi_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("€59/anno")
|
||||
expect(response.body).to include("€79/anno")
|
||||
expect(response.body).to include("€199/anno")
|
||||
expect(response.body).to include("€249/anno")
|
||||
expect(response.body).to include("€7,90/mese")
|
||||
expect(response.body).to include("€24,90/mese")
|
||||
expect(response.body).to include("equivale a €4,92/mese")
|
||||
expect(response.body).to include("equivale a €16,58/mese")
|
||||
expect(response.body).to include("Prezzo lancio")
|
||||
expect(response.body).to include("Consigliato per le società")
|
||||
expect(response.body).to include("Attiva Light — €59/anno")
|
||||
expect(response.body).to include("Attiva Light — €7,90/mese")
|
||||
expect(response.body).to include("Attiva Full — €199/anno")
|
||||
expect(response.body).to include("Attiva Full — €24,90/mese")
|
||||
expect(response.body).to include("Account trasmettitori: massimo")
|
||||
expect(response.body).to include("per abbonamento")
|
||||
expect(response.body).to include("Account trasmettitori")
|
||||
expect(response.body).to include("Niente password condivise")
|
||||
expect(response.body).to include("plan-card--featured")
|
||||
expect(response.body).not_to include("a vita")
|
||||
expect(response.body).not_to include("Più popolare")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,57 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public support page", type: :request do
|
||||
it "è pubblica, indicizzabile e mostra l'email di supporto configurata" do
|
||||
get public_support_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(MatchLiveTv.support_email)
|
||||
expect(response.body).to include("mailto:#{MatchLiveTv.support_email}")
|
||||
expect(response.body).to include(I18n.t("legal.support.h1", locale: :it))
|
||||
expect(response.body).to include('rel="canonical"')
|
||||
expect(response.body).to include(public_support_path)
|
||||
expect(response.body).to include(public_privacy_path)
|
||||
end
|
||||
|
||||
it "non espone CTA o link commerciali (App Store Review)" do
|
||||
get public_support_path
|
||||
body = response.body
|
||||
|
||||
expect(body).not_to include(public_prezzi_path)
|
||||
expect(body).not_to include(public_signup_path)
|
||||
expect(body).not_to include('href="/prezzi"')
|
||||
expect(body).not_to include('href="/signup"')
|
||||
expect(body).not_to include(I18n.t("nav.signup", locale: :it))
|
||||
expect(body).not_to include(I18n.t("nav.pricing", locale: :it))
|
||||
expect(body).not_to include(I18n.t("common.pricing", locale: :it))
|
||||
expect(body).not_to match(/\bPremium Light\b/i)
|
||||
expect(body).not_to match(/\bPremium Full\b/i)
|
||||
expect(body).not_to match(/\bpiano Free\b/i)
|
||||
expect(body).not_to match(/\bAbbonati\b/i)
|
||||
expect(body).not_to match(/\bAcquista\b/i)
|
||||
end
|
||||
|
||||
{
|
||||
"it" => "Supporto Match Live TV",
|
||||
"en" => "Match Live TV Support",
|
||||
"fr" => "Support Match Live TV",
|
||||
"de" => "Match Live TV Support",
|
||||
"es" => "Soporte Match Live TV"
|
||||
}.each do |locale, heading|
|
||||
it "renderizza correttamente in #{locale} senza translation missing" do
|
||||
cookies[:mltv_locale] = locale
|
||||
get public_support_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(heading)
|
||||
expect(response.body).not_to include("translation missing")
|
||||
end
|
||||
end
|
||||
|
||||
it "include /support nella sitemap" do
|
||||
get "/sitemap.xml"
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/support")
|
||||
end
|
||||
end
|
||||
@@ -10,7 +10,7 @@ RSpec.describe Billing::PaymentDescription do
|
||||
lines: double(data: [])
|
||||
)
|
||||
|
||||
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €5/mese")
|
||||
expect(described_class.for_stripe_invoice(invoice)).to eq("Premium Light — €7,90/mese")
|
||||
end
|
||||
|
||||
it "ignora descrizioni di test nel pagamento salvato" do
|
||||
@@ -23,6 +23,6 @@ RSpec.describe Billing::PaymentDescription do
|
||||
club: Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
|
||||
)
|
||||
|
||||
expect(payment.display_description).to eq("Premium Light — €40/anno")
|
||||
expect(payment.display_description).to eq("Premium Light — €59/anno")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -23,6 +23,15 @@ RSpec.describe Billing::Stripe::PriceCatalog do
|
||||
expect { described_class.normalize_interval("weekly") }.to raise_error(ArgumentError)
|
||||
end
|
||||
|
||||
it "etichetta il prezzo lancio annuale e il mensile" do
|
||||
expect(described_class.label(plan_slug: "premium_light", interval: "yearly")).to eq("€59/anno")
|
||||
expect(described_class.label(plan_slug: "premium_light", interval: "monthly")).to eq("€7,90/mese")
|
||||
expect(described_class.list_label(plan_slug: "premium_light", interval: "yearly")).to eq("€79/anno")
|
||||
expect(described_class.equivalent_monthly_label(plan_slug: "premium_light")).to eq("€4,92/mese")
|
||||
expect(described_class.label(plan_slug: "premium_full", interval: "yearly")).to eq("€199/anno")
|
||||
expect(described_class.label(plan_slug: "premium_full", interval: "monthly")).to eq("€24,90/mese")
|
||||
end
|
||||
|
||||
it "elenca intervalli configurati" do
|
||||
expect(described_class.available_intervals(plan_slug: "premium_light")).to eq(%w[monthly yearly])
|
||||
end
|
||||
|
||||
@@ -38,6 +38,6 @@ RSpec.describe Billing::Stripe::RecordPayment do
|
||||
expect(payment.club).to eq(club)
|
||||
expect(payment.amount_cents).to eq(4000)
|
||||
expect(payment.stripe_invoice_id).to eq("in_test_1")
|
||||
expect(payment.description).to eq("Premium Light — €40/anno")
|
||||
expect(payment.description).to eq("Premium Light — €59/anno")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe Streams::YoutubeRelay do
|
||||
describe ".youtube_ffmpeg_args (private)" do
|
||||
def args(mode, url)
|
||||
described_class.send(:youtube_ffmpeg_args, [mode, url], "rtmps://a.rtmps.youtube.com/live2/key")
|
||||
end
|
||||
|
||||
it "remuxes RTMP with video and audio copy (no AAC re-encode)" do
|
||||
cmd = args(:rtmp, "rtmp://mediamtx:1935/live/match_x")
|
||||
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-f", "flv")
|
||||
expect(cmd).not_to include("aac")
|
||||
expect(cmd.join(" ")).not_to include("-b:a")
|
||||
end
|
||||
|
||||
it "remuxes HLS with AAC bitstream filter for FLV" do
|
||||
cmd = args(:hls, "http://mediamtx:8888/live/match_x/index.m3u8")
|
||||
expect(cmd).to include("-c:v", "copy", "-c:a", "copy", "-bsf:a", "aac_adtstoasc", "-f", "flv")
|
||||
expect(cmd).not_to include("-b:a")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
# Android App Links / Digital Asset Links
|
||||
|
||||
Play Console verifica `https://www.matchlivetv.it/.well-known/assetlinks.json`.
|
||||
|
||||
## File
|
||||
|
||||
- Servito da **edge** (nginx): `infra/nginx-edge/well-known/assetlinks.json`
|
||||
- Specchio in Rails public: `backend/public/.well-known/assetlinks.json`
|
||||
|
||||
Package: `com.matchlivetv.match_live_tv`
|
||||
Path App Links: `https://www.matchlivetv.it/join…` (`pathPrefix="/join"`)
|
||||
|
||||
Nel JSON ci sono:
|
||||
|
||||
1. SHA-256 del **upload key** (`native/android/keystore/matchlivetv-upload.jks`)
|
||||
2. SHA-256 del certificato **App signing** di Play (da Play Console → Integrità dell’app)
|
||||
|
||||
Relazioni richieste da Play per la condivisione credenziali:
|
||||
|
||||
- `delegate_permission/common.handle_all_urls`
|
||||
- `delegate_permission/common.get_login_creds`
|
||||
|
||||
Se Play rigenera il JSON, sostituisci `infra/nginx-edge/well-known/assetlinks.json` (e lo specchio in `backend/public/.well-known/`) e ricarica edge.
|
||||
## Deploy edge (senza rebuild Rails)
|
||||
|
||||
Sul server:
|
||||
|
||||
```bash
|
||||
cd /opt/matchlivetv/infra
|
||||
# dopo git pull del repo
|
||||
docker compose -f docker-compose.prod.yml --env-file .env up -d edge
|
||||
curl -sS -D- https://www.matchlivetv.it/.well-known/assetlinks.json | head
|
||||
```
|
||||
|
||||
Verifica Google:
|
||||
|
||||
```bash
|
||||
curl -sS "https://digitalassetlinks.googleapis.com/v1/statements:list?source.web.site=https://www.matchlivetv.it&relation=delegate_permission/common.handle_all_urls"
|
||||
```
|
||||
|
||||
Poi in Play Console → Link diretti → ripeti i controlli dominio.
|
||||
@@ -38,16 +38,16 @@ Solo se la società vuole il **proprio** canale invece di Match Live TV:
|
||||
|
||||
Nella home app (**Partite**):
|
||||
|
||||
- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app)
|
||||
- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario
|
||||
- Oppure tocca una gara già in calendario (sito o app)
|
||||
|
||||
Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …).
|
||||
Poi conferma dal foglio o tocca la card: si apre il wizard (Partita → Trasmissione → …).
|
||||
|
||||
## 4. Avvia diretta
|
||||
|
||||
1. Nel wizard, seleziona **YouTube Live** (se il piano lo consente e il canale è pronto)
|
||||
2. Completa i passi → **Camera**
|
||||
3. Il telefono invia RTMP a MediaMTX; il server avvia **automaticamente** overlay (tabellone + logo) e RTMPS verso YouTube — nessun intervento manuale
|
||||
3. Il telefono invia RTMP a MediaMTX (con overlay tabellone già nel flusso, se attivo); il server avvia **automaticamente** il relay RTMPS verso YouTube (`Streams::YoutubeRelay`) — nessun intervento manuale
|
||||
|
||||
## Sviluppo locale
|
||||
|
||||
@@ -67,9 +67,9 @@ Vedi [YOUTUBE_MORNING_CHECKLIST.md](./YOUTUBE_MORNING_CHECKLIST.md) — APK **1.
|
||||
| YouTube disabilitato | Premium Light o Full |
|
||||
| YouTube non selezionabile | Token canale Match Live TV sul server (`admin` → YouTube piattaforma) |
|
||||
| OAuth «app non verificata» | Utente di test Google + Avanzate |
|
||||
| Live non su YouTube | Pipeline automatica (`Youtube::LivePipeline`): overlay → ingest active → activate. Controlla `docker logs infra-sidekiq-1` per `[OverlayRelay] started` e `[YoutubeBroadcastActivate]`. |
|
||||
| YouTube «In programma» / copertina | Nessun intervento manuale: entro ~20–90s il server avvia overlay (tee RTMPS) e attiva la broadcast quando ingest è `active`. |
|
||||
| YouTube resta in «waiting» | `log/overlay_relay_<session_id>.log` in **sidekiq**; poll ogni 5s (`StreamPublisherSyncJob`). Attivazione ritenta ~6 min. |
|
||||
| Live non su YouTube | Pipeline `Youtube::LivePipeline` + `YoutubeRelay`. Controlla `docker logs infra-sidekiq-1` per `[YoutubeRelay] started` / `[YoutubeBroadcastActivate]`. |
|
||||
| YouTube «In programma» / copertina | Entro ~20–90s il server avvia il relay RTMPS e attiva la broadcast quando l’ingest è pronto; in pausa MediaMTX manda la slate. |
|
||||
| YouTube resta in «waiting» | Log `log/youtube_relay_*.log` in **sidekiq**; poll `StreamPublisherSyncJob`. Attivazione ritenta ~6 min. |
|
||||
| Banner «sync limitata: Cannot add event after closing» | APK aggiornato: il wizard non apre un secondo WebSocket `controller` dopo la camera. |
|
||||
| Timer «IN DIRETTA» sbagliato (es. 10:33 subito) | `started_at` arriva dal server al primo frame RTMP; fino ad allora il timer resta a 0. |
|
||||
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: controlla permessi camera, messaggio «Connessione RTMP»; relay YouTube non parte finché il telefono non è online su MediaMTX. |
|
||||
|
||||
+25
-19
@@ -2,7 +2,7 @@
|
||||
|
||||
Documento di riferimento per l'intero sistema: rete, container Docker, flussi video, replay, monitoraggio ops e rilasci.
|
||||
|
||||
**Ultimo aggiornamento:** 2026-06-14
|
||||
**Ultimo aggiornamento:** 2026-07-29
|
||||
**Produzione:** `eminux@192.168.1.146` → `/opt/matchlivetv`
|
||||
|
||||
---
|
||||
@@ -193,39 +193,41 @@ Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md) per pause, overlay e player
|
||||
### Flusso ingest
|
||||
|
||||
```
|
||||
App Android (RTMP 720p, AAC mono)
|
||||
App (Android/iOS) RTMP 720p AAC mono
|
||||
+ overlay GPU (tabellone/watermark) se overlay_kind ≠ none
|
||||
│
|
||||
▼
|
||||
MediaMTX :1935 — path live/match_{uuid} (grezzo, telefono)
|
||||
│
|
||||
▼ overlay ffmpeg (Streams::OverlayRelay)
|
||||
path live/match_{uuid}_air (tabellone + badge bruciati nel video)
|
||||
MediaMTX :1935 — path live/match_{uuid}
|
||||
│
|
||||
├──► HLS :8888 ──► edge /hls/ ──► spettatori web
|
||||
└──► ffmpeg -c copy ──► YouTube RTMP (Streams::YoutubeRelay, Premium)
|
||||
└──► (solo platform=youtube)
|
||||
Streams::YoutubeRelay in Sidekiq
|
||||
ffmpeg: -c:v copy, -c:a copy ──► YouTube RTMPS
|
||||
```
|
||||
|
||||
| Componente | Ruolo |
|
||||
|------------|--------|
|
||||
| `Mediamtx::PathManager` | Crea path dinamici via API :9997 |
|
||||
| Path dinamici via API :9997 | `Mediamtx::Client` (`create_path`, recording patch, …) |
|
||||
| `alwaysAvailable` + slate | Copertina `/slates/offline.mp4` senza interrompere HLS |
|
||||
| `Streams::OverlayRelay` | Ricodifica con PNG 1280×720 aggiornata ogni ~2s |
|
||||
| `Streams::YoutubeRelay` | Legge HLS da `mediamtx:8888` (non più via Rails) |
|
||||
| `Mediamtx::PublisherSync` | Stato publisher online/offline in Rails |
|
||||
| Overlay tabellone | **App nativa** (non più ricodifica server) |
|
||||
| `Streams::YoutubeRelay` | Legge RTMP (o HLS) da MediaMTX; remux copy A/V → YouTube |
|
||||
| `Mediamtx::PublisherSync` | Publisher online/offline, go_live, recording on/off |
|
||||
|
||||
Dettaglio pause, player e ruolo ffmpeg: [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||
|
||||
### Pausa e continuità
|
||||
|
||||
1. App chiama `PATCH /sessions/:id/pause` → stop RTMP.
|
||||
2. MediaMTX passa alla slate sullo **stesso path** (stesso URL HLS).
|
||||
3. YouTube relay continua sulla stessa uscita (vede slate).
|
||||
3. Se attivo, YouTube relay continua sulla stessa uscita (vede slate).
|
||||
4. Ripresa: `resume` → app ripubblica RTMP; MediaMTX concatena camera senza nuovo URL.
|
||||
|
||||
**Recording inline MediaMTX disabilitato** (`record: false` su path live): il recorder al switch slate→camera distruggeva il muxer HLS. I replay usano job dedicato (vedi sotto).
|
||||
**Recording:** path creato con `record: false`; acceso solo con publisher online e entitlement (`PublisherSync`). I replay usano job dedicato (vedi sotto).
|
||||
|
||||
### Pagina live web
|
||||
|
||||
- URL: `/live/:session_id`
|
||||
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}_air/index.m3u8`
|
||||
- Player: HLS.js su `HLS_PUBLIC_URL/live/match_{uuid}/index.m3u8`
|
||||
- Stato: `GET /live/:id/status.json` (badge, recovery buffer, no-store)
|
||||
|
||||
---
|
||||
@@ -278,8 +280,8 @@ App Android ◄──── REST API (/api/v1/...) ────► Rails
|
||||
│
|
||||
Sidekiq ◄── Redis ◄──────┘
|
||||
│
|
||||
├── HealthMonitorJob, UploadJob, overlay refresh
|
||||
├── YoutubeRelay, OverlayRelay (ffmpeg)
|
||||
├── HealthMonitorJob, UploadJob (merge/thumbnail ffmpeg)
|
||||
├── YoutubeRelay (ffmpeg remux copy A/V, solo dirette YouTube)
|
||||
└── PublishToYoutubeJob, purge replay, ecc.
|
||||
```
|
||||
|
||||
@@ -395,10 +397,12 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
||||
| Documento | Contenuto |
|
||||
|-----------|-----------|
|
||||
| [`infrastructure/SERVER_DEPLOYMENT.md`](infrastructure/SERVER_DEPLOYMENT.md) | Bootstrap server, NPM, cron, backup |
|
||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pause, overlay, HLS.js |
|
||||
| [`infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md`](infrastructure/HETZNER_CLOUD_RELAY_OVERFLOW.md) | Piano overflow relay YouTube su Hetzner Cloud (picchi) |
|
||||
| [`ANDROID_APP_LINKS.md`](ANDROID_APP_LINKS.md) | Digital Asset Links / deep link Play (`assetlinks.json`) |
|
||||
| [`LIVE_STREAMING.md`](LIVE_STREAMING.md) | MediaMTX, pausa, HLS, ruolo ffmpeg |
|
||||
| [`REPLAY_MODULE.md`](REPLAY_MODULE.md) | Garage, retention, YouTube VOD |
|
||||
| [`OPS_MONITORING.md`](OPS_MONITORING.md) | ntfy, variabili ops, troubleshooting |
|
||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Grafica in stream |
|
||||
| [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md) | Tabelloni e overlay (app nativa) |
|
||||
| [`PRODUCT_PREMIUM.md`](PRODUCT_PREMIUM.md) | Piani e funzionalità premium |
|
||||
| [`native/android/README.md`](../native/android/README.md) | App Android |
|
||||
|
||||
@@ -412,5 +416,7 @@ cd infra && cp .env.example .env && docker compose up -d --build
|
||||
| 2026-06 | Replay: redirect 302 a `/media/` → Garage (fuori da Puma) |
|
||||
| 2026-06 | HLS live: edge → MediaMTX (fuori da Puma) |
|
||||
| 2026-06 | Ops: check `http_rails` + `http_public` separati, latenza p95 `UpLatencyTracker` |
|
||||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube legge `mediamtx:8888` diretto |
|
||||
| 2026-06 | `RAILS_MAX_THREADS=5`; relay YouTube da MediaMTX (non via Puma) |
|
||||
| 2026-06 | Cleanup path MediaMTX orfani (`Mediamtx::CleanupOrphanPaths`); delete path sempre a fine diretta |
|
||||
| 2026-07 | Rimosso overlay server (`OverlayRelay`); tabellone bruciato in app; HLS su path camera (non `*_air`); `YoutubeRelay` = copy video + AAC |
|
||||
| 2026-08 | `YoutubeRelay` = remux copy video+audio (niente ricodifica AAC); profilo app/slate AAC 48k mono |
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Gap Android → iOS: allineamento app nativa
|
||||
|
||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android `2.0.5-native`**.
|
||||
Documento operativo per continuare su **Mac** lo sviluppo iOS e rilasciare un’app **allineata ad Android**.
|
||||
|
||||
**Aggiornato:** 24 luglio 2026
|
||||
**Riferimento Android (produzione / telefono / Play):** `2.0.5-native` (`versionCode` **26**), API `https://www.matchlivetv.it`
|
||||
**Stato iOS attuale:** marketing `2.0.5`, build `26` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast)
|
||||
**Aggiornato:** 8 agosto 2026
|
||||
**Riferimento Android (produzione / telefono / Play):** `2.0.10-native` (`versionCode` **31**), API `https://www.matchlivetv.it`
|
||||
**Stato iOS attuale:** marketing `2.0.10`, build `31` — i18n **allineato** (Login, hub, sheet/dialog, wizard, broadcast, account/forgot)
|
||||
|
||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast) + bump versione, **senza** i bug di sessione/crash già risolti su Android 2.0.5.
|
||||
Obiettivo iOS: **stessa copertura lingua** di Android (Login, hub, sheet/dialog, wizard, broadcast, account) + bump versione, **senza** i bug di sessione/crash già risolti su Android.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +59,7 @@ Queste sono regressioni/bug già visti su Android; **non ripeterli** su iOS.
|
||||
| Broadcast + score dialog | Sì | `broadcast.*` / `score.*` | **Fatto** |
|
||||
| Catalogo stringhe | `values*` (~230) | `AppLanguage.swift` L10n (~225, no FGS) | **Fatto** |
|
||||
| Logout UI | Icona | Icona SF Symbol | **Fatto** |
|
||||
| Versione | `2.0.5-native` / **26** | `2.0.5` / **26** | **Fatto** |
|
||||
| Versione | `2.0.10-native` / **31** | `2.0.10` / **31** | **Fatto** |
|
||||
| RTMP ingest | `RtmpIngestUrl.kt` | `MediaUrl.swift` | OK |
|
||||
|
||||
### Residui fuori scope Android (opzionali)
|
||||
@@ -141,7 +141,7 @@ xcodebuild -project MatchLiveTv.xcodeproj -scheme MatchLiveTv \
|
||||
|
||||
Nessuna modifica server richiesta.
|
||||
|
||||
Versione store target: **`2.0.5` / build `26`** (parità con Android `2.0.5-native` / versionCode `26`).
|
||||
Versione store target: **`2.0.10` / build `31`** (parità con Android `2.0.10-native` / versionCode `31`).
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
# iOS — allineamento password policy + errori API localizzati
|
||||
|
||||
Documento operativo per **Cursor su Mac**: allineare l’app iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`).
|
||||
|
||||
**Aggiornato:** 2026-08-08
|
||||
**Android di riferimento:** `2.0.10-native` (`versionCode` **31**)
|
||||
**iOS (allineato):** marketing `2.0.10` / build `31`
|
||||
**API produzione:** `https://www.matchlivetv.it`
|
||||
|
||||
---
|
||||
|
||||
## Contesto (già in produzione backend dopo questo rilascio)
|
||||
|
||||
### Policy password (server)
|
||||
|
||||
Definita in `backend/app/models/concerns/password_complexity.rb`:
|
||||
|
||||
- minimo **8** caratteri, massimo **72** byte (bcrypt)
|
||||
- almeno **3 classi su 4**: minuscole, maiuscole, numeri, simboli
|
||||
- in **cambio password** e **reset**: la nuova password **non** può coincidere con quella attuale
|
||||
|
||||
Validazione ActiveModel su `User` / `AdminAccount`. Endpoint che la applicano:
|
||||
|
||||
| Endpoint | Note |
|
||||
|----------|------|
|
||||
| `PATCH /api/v1/account/password` | App native + stessi codici errore |
|
||||
| `PATCH /account/password` (web) | Flash I18n |
|
||||
| reset password pubblico | Stesse regole |
|
||||
| registrazione (`auth/register`) | Validazione modello |
|
||||
|
||||
### Errori API localizzati
|
||||
|
||||
`ApplicationController#set_api_locale` legge, in ordine:
|
||||
|
||||
1. header `X-Locale`
|
||||
2. `Accept-Language`
|
||||
3. `I18n.default_locale` (fallback; **retrocompatibile** se l’app vecchia non manda nulla)
|
||||
|
||||
Risposta invariata: `{ "error": "<messaggio già tradotto>" }` (o `{ "message": "..." }` su alcuni successi).
|
||||
|
||||
Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`):
|
||||
|
||||
- `flash.accounts.password_too_short`
|
||||
- `flash.accounts.password_too_long`
|
||||
- `flash.accounts.password_too_weak`
|
||||
- `flash.accounts.password_same_as_current`
|
||||
- `flash.accounts.password_current_incorrect`
|
||||
- `flash.accounts.password_mismatch`
|
||||
- `flash.accounts.password_updated` / `name_required`
|
||||
- analoghi in `flash.password_resets.*` e `flash.sessions.*`
|
||||
|
||||
---
|
||||
|
||||
## Stato iOS vs Android
|
||||
|
||||
| Area | Android 2.0.10 | iOS | Stato |
|
||||
|------|----------------|-----|-------|
|
||||
| Header `Accept-Language` su tutte le request API | OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **OK** |
|
||||
| Hint UI nuova password | `account_new_password` | `account.new.password` | **OK** |
|
||||
| Schermata Account | AccountScreen | AccountScreen | **OK** |
|
||||
| Forgot password | ForgotPasswordScreen | ForgotPasswordScreen | **OK** |
|
||||
| Parsing errori API | body `error` | `APIError.friendlyHttpMessage` | **OK** |
|
||||
| Client-side pre-check complessità | No | No | N/A (come Android) |
|
||||
| Versione store | `2.0.10-native` / **31** | `2.0.10` / **31** | **OK** |
|
||||
| Signup in-app | Non principale | Nessuna UI register | N/A (signup web) |
|
||||
|
||||
### File iOS
|
||||
|
||||
```
|
||||
native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
|
||||
native/ios/MatchLiveTv/Core/AppLanguage.swift
|
||||
native/ios/MatchLiveTv/UI/Account/AccountScreen.swift
|
||||
native/ios/MatchLiveTv/UI/Login/ForgotPasswordScreen.swift
|
||||
native/ios/MatchLiveTv/Resources/Info.plist
|
||||
native/ios/generate_xcodeproj.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Criterio “fatto”
|
||||
|
||||
- [x] `Accept-Language` su login, me, account, change password, forgot (`MatchLiveAPI`)
|
||||
- [x] Hint campo nuova password parla di “min. 8 / 3 tipi”
|
||||
- [x] Catalogo stringhe account/forgot in it/en/fr/de/es
|
||||
- [x] Versione bumpata a `2.0.10` / `31`
|
||||
- [ ] QA manuale errori password in lingua UI (weak / same / mismatch / success)
|
||||
|
||||
Quando rilasci su TestFlight/App Store, conferma la build `31`.
|
||||
+47
-32
@@ -1,23 +1,25 @@
|
||||
# Diretta live — architettura MediaMTX
|
||||
|
||||
## Idea (non è folle)
|
||||
**Ultimo aggiornamento:** 2026-07-29
|
||||
|
||||
## Idea
|
||||
|
||||
MediaMTX espone **un unico flusso di uscita** per path (`live/match_{uuid}`):
|
||||
|
||||
1. **Telefono in onda** → publisher RTMP (camera).
|
||||
1. **Telefono in onda** → publisher RTMP (camera + **overlay grafico bruciato in app**).
|
||||
2. **Pausa / rete assente** → `alwaysAvailable` con file slate (`/slates/offline.mp4`) **senza interrompere** l’HLS verso il sito.
|
||||
3. **YouTube** → `ffmpeg` in container Rails/Sidekiq legge **sempre** quell’uscita (`-c copy`) e inoltra a `rtmp://a.rtmp.youtube.com/live2/...` per tutta la sessione.
|
||||
3. **YouTube** (solo se `platform=youtube`) → `Streams::YoutubeRelay` in **Sidekiq**: un processo `ffmpeg` legge RTMP/HLS da MediaMTX e inoltra a YouTube (`-c:v copy`, `-c:a copy`; l’AAC 48 kHz mono è già prodotto dall’app / slate).
|
||||
|
||||
Il telefono **non** invia la copertina: risparmia banda; lo switch è lato server.
|
||||
Il telefono **non** invia la copertina di pausa: risparmia banda; lo switch slate ↔ camera è lato MediaMTX.
|
||||
|
||||
## Componenti
|
||||
|
||||
| Pezzo | Ruolo |
|
||||
|--------|--------|
|
||||
| App Android | RTMP 48 kHz mono, 720p — solo quando in onda |
|
||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate |
|
||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (no hook wget su distroless) |
|
||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) |
|
||||
| App Android / iOS | RTMP 720p AAC mono; overlay tabellone/watermark in GPU sul flusso |
|
||||
| MediaMTX | Path dinamico, `alwaysAvailable` + slate, HLS |
|
||||
| `Streams::YoutubeRelay` | Relay continuo verso YouTube (solo Sidekiq con `YOUTUBE_RELAY_WORKER=1`) |
|
||||
| `Mediamtx::PublisherSync` | Stato Rails da API paths (publisher online) + recording on/off |
|
||||
| `/hls/...` (edge → MediaMTX in prod; Rails proxy in dev) | Player web |
|
||||
|
||||
## Pausa e continuità
|
||||
@@ -27,36 +29,44 @@ Il telefono **non** invia la copertina: risparmia banda; lo switch è lato serve
|
||||
3. MediaMTX → passa alla slate sul **medesimo path** (senza interrompere l’uscita HLS).
|
||||
4. **Ripresa** → API `resume` → `connecting`, recording on, app `resumeStream()`; MediaMTX concatena di nuovo camera sulla stessa uscita HLS.
|
||||
5. **Sito** → **un solo** player HLS per tutta la sessione (mai reload in pausa/ripresa). Copertina brand (`brand/CopertinaCanale_6.png` → `infra/slates/offline.mp4`) muxata da MediaMTX; in pausa solo un messaggio leggero sopra il video.
|
||||
6. **YouTube** → relay ffmpeg continuo; vede la stessa uscita MediaMTX.
|
||||
6. **YouTube** → se attivo, il relay ffmpeg continua sulla stessa uscita MediaMTX (vede slate in pausa).
|
||||
|
||||
Non fare `patch` del path MediaMTX in pausa: ricarica il path e interrompe gli HLS reader.
|
||||
|
||||
**Recording**: disabilitato sul path live (`record: false`) — il recorder MediaMTX al switch slate→camera distruggeva il muxer HLS (`too many reordered frames`). I replay vanno gestiti con job dedicato (vedi `REPLAY_MODULE.md`).
|
||||
**Recording**: sul path live parte `record: false`; viene acceso via API solo con publisher online e entitlement (`PublisherSync`). I replay finiscono in Garage con job dedicato (vedi `REPLAY_MODULE.md`).
|
||||
|
||||
Il player web resta attivo finché `ready|available|online` sul path (non solo quando il telefono è `online`).
|
||||
|
||||
## Infrastruttura vs browser
|
||||
|
||||
```
|
||||
Telefono RTMP ──► MediaMTX path live/match_{uuid}
|
||||
Telefono RTMP (camera + overlay app)
|
||||
│
|
||||
publisher ON ├─► camera (H.264/AAC)
|
||||
publisher OFF└─► alwaysAvailable → /slates/offline.mp4
|
||||
▼
|
||||
MediaMTX path live/match_{uuid}
|
||||
│
|
||||
├─► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX (prod) o proxy Rails (dev) ──► player web (HLS.js)
|
||||
└─► RTMP lettura ──► Streams::YoutubeRelay (ffmpeg -c copy) ──► YouTube
|
||||
publisher ON → camera H.264/AAC (già con tabellone se overlay ≠ none)
|
||||
publisher OFF → alwaysAvailable → /slates/offline.mp4
|
||||
│
|
||||
├──► HLS (segmenti ~1s) ──► edge /hls/ → MediaMTX ──► player web (HLS.js)
|
||||
└──► (solo platform=youtube)
|
||||
Streams::YoutubeRelay
|
||||
ffmpeg: -c:v copy, -c:a copy ──► RTMPS YouTube
|
||||
```
|
||||
|
||||
| Responsabilità | Dove |
|
||||
|----------------|------|
|
||||
| Switch copertina ↔ live | **MediaMTX** (stesso path, stesso URL HLS) |
|
||||
| Continuità YouTube | **Relay ffmpeg** sulla stessa uscita |
|
||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) |
|
||||
| Tabellone / watermark sullo stream | **App nativa** (GPU → RTMP) |
|
||||
| Continuità YouTube | **`Streams::YoutubeRelay`** (ffmpeg in Sidekiq) |
|
||||
| Badge «In onda» / stato pausa | **Rails** (`status.json`, `PublisherSync`) + UI web |
|
||||
| Uscire dal buffer copertina dopo ripresa | **Browser** (`pendingLiveRecovery`, reload HLS) |
|
||||
| Fluidità playback | **Browser** (evitare seek continui su `liveSyncPosition`) |
|
||||
|
||||
Lo switch slate→camera **non** richiede un nuovo URL lato player: MediaMTX concatena sulla playlist. Il sito può però restare «indietro» nel buffer HLS (ancora segmenti della slate) anche con `publisher_online: true` — da qui i recovery controllati in `show.html.erb`, **senza** chiamare `jumpToLiveEdge()` a ogni frammento bufferizzato.
|
||||
|
||||
URL HLS pubblico: `https://www.matchlivetv.it/hls/live/match_{uuid}/index.m3u8` (`StreamSession#effective_hls_path_name` = path camera, **non** più `*_air`).
|
||||
|
||||
### Player web — anti-scatti (HLS.js)
|
||||
|
||||
Configurazione in `backend/app/views/public/live/show.html.erb`:
|
||||
@@ -67,27 +77,32 @@ Configurazione in `backend/app/views/public/live/show.html.erb`:
|
||||
- Sync iniziale una volta su `LEVEL_LOADED`; recovery solo se `liveEdgeLagSec() > 4` o `pendingLiveRecovery`.
|
||||
- Interval 6s solo durante recovery attivo (`tryEscapeCoverBuffer`).
|
||||
|
||||
### Grafica nel video (overlay server)
|
||||
### Grafica nel video (overlay)
|
||||
|
||||
Tabellone, badge stato («In onda», «In pausa», «Copertina», …) e banner **Match Live TV** sono **bruciati nel flusso** da `Streams::OverlayRelay`:
|
||||
**Non** c’è più un relay server `Streams::OverlayRelay` che ricodifica con PNG.
|
||||
|
||||
```
|
||||
Telefono → live/match_{uuid} (grezzo)
|
||||
└─► ffmpeg + PNG 1280×720 (Streams::Overlay::Refresh ogni ~2s)
|
||||
└─► live/match_{uuid}_air
|
||||
├─► HLS (sito, anche fullscreen)
|
||||
└─► YouTube relay (-c copy)
|
||||
```
|
||||
Il tabellone è composito **sul telefono** prima dell’RTMP (vedi [`TABELLONI_E_OVERLAY.md`](TABELLONI_E_OVERLAY.md)):
|
||||
|
||||
- `Streams::Overlay::SvgBuilder` + `rsvg-convert` generano `tmp/stream_overlays/{session_id}/overlay.png`
|
||||
- `Streams::Overlay::Refresh` + `bin/overlay_png_feeder.rb` aggiornano la PNG ogni ~2s (e su punteggio/stato); il feeder la invia a ffmpeg via FIFO perché `-loop 1` non rilegge il file su disco
|
||||
- Volume Docker condiviso `stream_overlays` tra **rails** (ffmpeg) e **sidekiq** (OverlayRefreshJob); altrimenti il job scrive PNG su un filesystem diverso e badge/punteggio restano congelati
|
||||
- La pagina live **non** sovrappone più HTML sul player (evita incongruenze con fullscreen / YouTube)
|
||||
- **Qualità video**: il relay normalizza a **30 fps CFR** (l’app RTMP può inviare timestamp errati) e ricodifica a **≥4.5 Mbps** (`fast`, no B-frame). Override: `OVERLAY_RELAY_VIDEO_KBPS`, `OVERLAY_RELAY_X264_PRESET`, `OVERLAY_RELAY_FPS`.
|
||||
1. `OverlayState` da `effectiveOverlayKind` + `ScoreState`
|
||||
2. `OverlayRenderer` / canvas → bitmap
|
||||
3. Filtro GPU sul encoder RTMP
|
||||
|
||||
La pagina live può mostrare badge HTML di stato (in onda / pausa / attesa); non sostituisce il tabellone nel video.
|
||||
|
||||
### Ruolo di ffmpeg oggi
|
||||
|
||||
| Quando | Cosa | Peso CPU |
|
||||
|--------|------|----------|
|
||||
| Diretta `platform=youtube` | `YoutubeRelay`: demux + **copy video/audio** → RTMPS (remux) | Basso **per diretta**, scala con N processi |
|
||||
| Diretta solo `matchlivetv` | Nessun ffmpeg in live | Quasi zero |
|
||||
| Fine diretta | `UploadFromSession`: concat segmenti (`-c copy`) | Picco breve |
|
||||
| Post-process | `GenerateThumbnail`: un frame → JPEG | Trascurabile |
|
||||
|
||||
Non esiste più la ricodifica H.264 server-side dell’overlay (era il carico principale).
|
||||
|
||||
### Punteggio (persistenza)
|
||||
|
||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (`Scoring::SyncState`), poi refresh overlay.
|
||||
L’app mobile persiste il punteggio con `PATCH /api/v1/sessions/:id/score` (o `score_action`) → `Scoring::SyncState`, poi aggiorna l’overlay locale in camera.
|
||||
|
||||
`GET /live/:id/status.json` resta per messaggi sotto il player e recovery HLS; `Cache-Control: no-store`.
|
||||
|
||||
|
||||
@@ -4,18 +4,19 @@
|
||||
|
||||
| | Free | Premium Light | Premium Full |
|
||||
|---|------|---------------|----------------|
|
||||
| Responsabili trasmissione / anno | 1 | 5 | Illimitato |
|
||||
| Account trasmettitori | 1 | 5 | Illimitati |
|
||||
| Partite in contemporanea | 1 | 3 | 10 |
|
||||
| Live Match Live TV | Sì | Sì | Sì |
|
||||
| YouTube | No | Match Live TV | Canale società |
|
||||
| Replay server | No | 30 giorni | 90 giorni |
|
||||
| Download telefono | No | Sì | Sì |
|
||||
| Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese |
|
||||
| Prezzo lancio | €0 | €59/anno o €7,90/mese | €199/anno o €24,90/mese |
|
||||
| Listino | — | €79/anno | €249/anno |
|
||||
| Embed sito società | No | No | In arrivo |
|
||||
|
||||
Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone.
|
||||
|
||||
Esigenze custom: contatto `info@matchlive.it`.
|
||||
Esigenze custom: contatto `info@matchlivetv.it`.
|
||||
|
||||
## Sito
|
||||
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
### Prodotti e prezzi
|
||||
Crea **2 prodotti** in Stripe, ciascuno con **2 prezzi ricorrenti**:
|
||||
|
||||
| Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale | Variabile `.env` |
|
||||
| Piano app | Prodotto Stripe (Dashboard) | Mensile | Annuale (lancio) | Variabile `.env` |
|
||||
|-----------|------------------------------|---------|---------|------------------|
|
||||
| Premium Light | **Match Live TV** | €5 | €40 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||
| Premium Full | **Match Live TV PRO** | €20 | €200 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||
| Premium Light | **Match Live TV** | €7,90 | €59 | `STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||
| Premium Full | **Match Live TV PRO** | €24,90 | €199 | `STRIPE_PREMIUM_FULL_MONTHLY_PRICE_ID` / `..._YEARLY_...` |
|
||||
|
||||
Gli importi in Stripe Dashboard devono coincidere con il sito (€5/€40 e €20/€200).
|
||||
Gli importi in Stripe Dashboard devono coincidere con i **prezzi lancio** addebitati sul sito (€7,90/€59 e €24,90/€199). I listini barrati (€79 e €249) sono solo display, non vanno creati come Price Stripe.
|
||||
|
||||
In Stripe apri ogni prezzo e copia l’**ID prezzo** (`price_...`), non l’ID prodotto.
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
Questo documento descrive come Match Live TV modella gli sport, applica i regolamenti di punteggio e li traduce in **tabelloni** (logica + controlli) e **overlay** (grafica sul video in diretta).
|
||||
|
||||
**Importante (2026-07):** l’overlay video è composito **solo sull’app nativa** (Android/iOS) e viaggia già nel RTMP verso MediaMTX. Non esiste più un relay server (`Streams::OverlayRelay`) che ricodifica con PNG. Vedi anche [`LIVE_STREAMING.md`](LIVE_STREAMING.md).
|
||||
|
||||
---
|
||||
|
||||
## Panoramica
|
||||
@@ -162,12 +164,14 @@ Esempio: il basket ammette `[basket, none]` — si può trasmettere con tabellon
|
||||
|
||||
### Layout app mobile (Android e iOS)
|
||||
|
||||
Sull’app nativa l’overlay è composito in GPU senza ricodifica aggiuntiva:
|
||||
Sull’app nativa l’overlay è composito in GPU **prima** dell’encode RTMP (nessuna ricodifica server aggiuntiva):
|
||||
|
||||
1. `BroadcastScreen` costruisce un `OverlayState` a partire da `effectiveOverlayKind` e `ScoreState`.
|
||||
2. `OverlayRenderer` → `OverlayCanvasRenderer` disegna gli elementi su bitmap trasparente.
|
||||
3. Il bitmap viene applicato come filtro sul flusso RTMP (OpenGL su Android, HaishinKit su iOS).
|
||||
|
||||
MediaMTX e `YoutubeRelay` ricevono quindi un flusso già “brandizzato”; il relay YouTube fa solo remux (`-c copy` video e audio).
|
||||
|
||||
Elementi grafici (`OverlayCanvasRenderer`):
|
||||
|
||||
| Elemento | Quando è attivo |
|
||||
|
||||
@@ -1,56 +1,32 @@
|
||||
# YouTube Live — verificato ✅ (5 giu 2026)
|
||||
# YouTube Live — checklist operativa
|
||||
|
||||
## Test completato automaticamente
|
||||
**Aggiornato:** 2026-07-29
|
||||
Pipeline attuale: telefono → MediaMTX → `Streams::YoutubeRelay` (ffmpeg remux copy A/V in Sidekiq) → YouTube. Overlay tabellone in **app**, non più `OverlayRelay` server.
|
||||
|
||||
Pipeline end-to-end **funzionante** sul server di produzione:
|
||||
|
||||
| Step | Esito |
|
||||
|------|--------|
|
||||
| Setup broadcast + stream_key | ✅ |
|
||||
| Overlay ffmpeg con tee RTMPS YouTube | ✅ |
|
||||
| Copertina / tabellone su YouTube | ✅ |
|
||||
| RTMP simulato (test pattern) | ✅ |
|
||||
| Broadcast `lifecycle=live` | ✅ |
|
||||
|
||||
**Prova visiva:** https://www.youtube.com/watch?v=0WmCsW_Luik
|
||||
(Titolo: *Tigers Volley vs Avversario prova* — sessione di test, ora terminata)
|
||||
|
||||
## APK da installare sul telefono
|
||||
|
||||
```bash
|
||||
# Sul PC (già compilato):
|
||||
mobile/build/app/outputs/flutter-apk/app-release.apk
|
||||
# Versione: 1.2.10+13 — API https://www.matchlivetv.it
|
||||
```
|
||||
|
||||
Installa sul Redmi (sostituisce versioni precedenti).
|
||||
|
||||
## Avvio diretta dall’app (2 minuti)
|
||||
|
||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico, fino a 3 min)
|
||||
3. Avvia: entro ~90s su YouTube compare **copertina**, poi video con tabellone quando il telefono pubblica RTMP
|
||||
|
||||
## Test server senza telefono
|
||||
## Verifica rapida su server
|
||||
|
||||
```bash
|
||||
ssh eminux@192.168.1.146
|
||||
docker exec infra-rails-1 bin/rails youtube:e2e
|
||||
docker exec infra-rails-1 bin/rails youtube:e2e # se il task è ancora presente
|
||||
docker exec infra-sidekiq-1 pgrep -a ffmpeg # deve contenere rtmps://...youtube.com/live2
|
||||
docker logs infra-sidekiq-1 2>&1 | grep -E 'YoutubeRelay|YoutubeBroadcastActivate|LivePipeline' | tail -40
|
||||
```
|
||||
|
||||
## Avvio diretta dall’app
|
||||
|
||||
1. Partita → **YouTube Live** → wizard → **Camera**
|
||||
2. Attendi sul passo rete che compaia il link YouTube (poll automatico)
|
||||
3. Avvia: entro ~90s su YouTube compare video (slate MediaMTX se il telefono non pubblica ancora; tabellone quando l’app pubblica RTMP con overlay)
|
||||
|
||||
## Se qualcosa non va
|
||||
|
||||
| Sintomo | Cosa fare |
|
||||
|---------|-----------|
|
||||
| «YouTube temporaneamente occupato» | Attendi 15–20 min (rate limit Google). **Non** creare molte sessioni di fila. |
|
||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep OverlayRelay` — manca `stream_key` o tee RTMPS |
|
||||
| App errore avvio diretta | APK 1.2.10+13; controlla rete verso matchlivetv.it |
|
||||
| Pagina YouTube vuota | `docker logs infra-sidekiq-1 \| grep YoutubeRelay` — manca `stream_key` o relay non partito |
|
||||
| App 0 Mbps / 0 fps ma LIVE | RTMP non pubblica: permessi camera / messaggio «Connessione RTMP»; relay YouTube non parte finché MediaMTX non vede il publisher |
|
||||
| YouTube resta in waiting | Log relay in sidekiq; `StreamPublisherSyncJob` + activate retry |
|
||||
|
||||
## Fix deployati (server)
|
||||
## Note storiche
|
||||
|
||||
- Retry setup broadcast su rate limit (fino a 15 tentativi)
|
||||
- Un solo job setup per sessione (lock Redis)
|
||||
- Throttle API YouTube globale
|
||||
- Riavvio overlay quando arriva `stream_key` dopo start senza tee
|
||||
- Activate solo con overlay attivo + broadcast pronta
|
||||
Checklist precedente (giu 2026) citava `OverlayRelay` / tee RTMPS dall’overlay server: **deprecato**. Non cercare più log `OverlayRelay` o `overlay_relay_*.log`.
|
||||
|
||||
@@ -7,7 +7,7 @@ Match Live TV supporta due modalità YouTube, legate al piano società:
|
||||
| **Premium Light** | `matchlivetv_light` | Canale YouTube **Match Live TV** (gestito da te) |
|
||||
| **Premium Full** | `team` | Canale YouTube **della società** (OAuth del club) |
|
||||
|
||||
Flusso tecnico: telefono → RTMP MediaMTX → (relay ffmpeg) → YouTube Live.
|
||||
Flusso tecnico: telefono (camera + overlay app) → RTMP MediaMTX → `Streams::YoutubeRelay` (ffmpeg `-c:v copy -c:a copy`) → YouTube Live.
|
||||
|
||||
---
|
||||
|
||||
@@ -85,5 +85,5 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
||||
## 6. Produzione
|
||||
|
||||
- `YOUTUBE_REDIRECT_URI` deve essere **esattamente** quello registrato in Google (HTTPS, dominio pubblico).
|
||||
- Il relay ffmpeg gira nel container MediaMTX (`runOnReady`).
|
||||
- Il relay ffmpeg (`Streams::YoutubeRelay`) gira nel container **sidekiq** (`YOUTUBE_RELAY_WORKER=1`), non su MediaMTX `runOnReady`.
|
||||
- Apri RTMP **1935** sul router verso il server.
|
||||
|
||||
@@ -0,0 +1,408 @@
|
||||
# Piano: overflow relay YouTube su Hetzner Cloud
|
||||
|
||||
**Stato:** piano / design — non implementato
|
||||
**Ultimo aggiornamento:** 2026-07-29
|
||||
**Contesto:** produzione attuale su host dedicato (es. Proxmox/Hetzner) con MediaMTX + Sidekiq sullo stesso box; obiettivo >10 live YouTube contemporanee senza abbandonare il middle-tier.
|
||||
|
||||
Documenti correlati: [`LIVE_STREAMING.md`](../LIVE_STREAMING.md), [`ARCHITECTURE.md`](../ARCHITECTURE.md), [`OPS_MONITORING.md`](../OPS_MONITORING.md).
|
||||
|
||||
---
|
||||
|
||||
## 1. Scenario
|
||||
|
||||
### Problema di prodotto
|
||||
|
||||
Match Live TV disaccoppia il telefono da YouTube:
|
||||
|
||||
```text
|
||||
Telefono (rete mobile instabile)
|
||||
│ RTMP
|
||||
▼
|
||||
MediaMTX (sempre attivo, slate / alwaysAvailable)
|
||||
│
|
||||
├── HLS → sito
|
||||
└── YoutubeRelay (ffmpeg) → RTMPS YouTube
|
||||
```
|
||||
|
||||
Se cade solo il tratto telefono→MediaMTX, YouTube resta in onda (slate).
|
||||
Se il telefono andasse diretto a YouTube (o a un ingest cloud senza hold), la live pubblica cadrebbe con la rete mobile. Inoltre YouTube non è pensato come “Go Live dall’app mobile” senza vincoli di canale: noi usiamo API + RTMPS dal server.
|
||||
|
||||
### Problema di capacità
|
||||
|
||||
Ogni diretta `platform=youtube` avvia un processo `ffmpeg` in Sidekiq (`Streams::YoutubeRelay`: video+audio **copy** / remux).
|
||||
Il carico scala con **N processi**, non con gli spettatori HLS.
|
||||
|
||||
Sul box attuale (~4 CPU / 8 GB, stack completo sullo stesso host):
|
||||
|
||||
| Live YT contemporanee | Situazione attesa |
|
||||
|-----------------------|-------------------|
|
||||
| 1–4 | Comodo |
|
||||
| 5–8 | Teso (CPU/RAM, latenza job Sidekiq) |
|
||||
| ≥10 | Rischioso (relay instabili, watchdog, slate prolungate su YT) |
|
||||
|
||||
**Obiettivo:** mantenere MediaMTX (e Rails) sul dedicated; quando N supera la soglia comoda, **accendere capacità Hetzner Cloud solo per i relay**, per il tempo del picco, poi spegnerla.
|
||||
|
||||
### Cosa NON è questo piano
|
||||
|
||||
- Non sostituisce MediaMTX con Mux/AWS MediaLive.
|
||||
- Non sposta l’ingest RTMP del telefono sul cloud (cold start e sticky URL sono incompatibili con “zero nodi a riposo”).
|
||||
- Non è autoscaling del monolite Docker Compose intero.
|
||||
|
||||
---
|
||||
|
||||
## 2. Principio di progettazione
|
||||
|
||||
| Componente | Dove resta | Perché |
|
||||
|------------|------------|--------|
|
||||
| MediaMTX (RTMP ingest + slate + HLS) | **Dedicated sempre acceso** | URL stabile per l’app; continuità YouTube via slate |
|
||||
| Rails / Postgres / Redis / Garage / edge | **Dedicated** (o gestiti fissi) | Stato, auth, replay; non sono il collo di bottiglia video live |
|
||||
| `YoutubeRelay` (ffmpeg) | **Dedicated fino a soglia + overflow Cloud** | Unico pezzo che moltiplica CPU con N live YT |
|
||||
| Job Sidekiq non-video (mail, ops, post-process) | Dedicated (coda separata) | Non devono competere con ffmpeg sui worker overflow |
|
||||
|
||||
Formula mentale:
|
||||
|
||||
```text
|
||||
capacità_yt ≈ core_dedicati_relay + Σ core_vm_overflow_calde
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Architettura target (overflow)
|
||||
|
||||
```text
|
||||
[ telefoni RTMP ]
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────┐
|
||||
│ Dedicated (sempre on) │
|
||||
│ MediaMTX · Rails · DB │
|
||||
│ Redis · Garage · edge │
|
||||
│ Sidekiq "core" │
|
||||
│ (+ pochi relay locali) │
|
||||
└───────────┬─────────────┘
|
||||
│
|
||||
intake RTMP/HLS │ (rete privata / tunnel / IP allowlist)
|
||||
│
|
||||
┌───────────────┼───────────────┐
|
||||
▼ ▼ ▼
|
||||
[relay-local] [overflow-1] [overflow-N]
|
||||
Sidekiq+ffmpeg Hetzner Cloud Hetzner Cloud
|
||||
YOUTUBE_RELAY=1 a ore a ore
|
||||
│ │ │
|
||||
└───────────────┴───────────────┘
|
||||
│
|
||||
▼
|
||||
YouTube RTMPS
|
||||
```
|
||||
|
||||
### Ruolo delle VM overflow
|
||||
|
||||
Immagine minimale (o Compose snello):
|
||||
|
||||
- Sidekiq con `YOUTUBE_RELAY_WORKER=1`
|
||||
- `ffmpeg` disponibile
|
||||
- Connessione a **Redis** del dedicated (coda + lock `youtube_relay:owner:*`)
|
||||
- Lettura intake da MediaMTX (RTMP preferito, HLS fallback come oggi)
|
||||
- **Niente** Postgres scrittura diretta obbligatoria se i job relay usano già Redis + API; in pratica oggi Rails/Sidekiq legge Postgres → le VM overflow devono raggiungere anche DB **oppure** si isola una coda “relay-only” con worker che riceve payload già risolto (vedi §5.2)
|
||||
|
||||
Stato attuale del codice: `YoutubeRelay` gira in processo Sidekiq Rails completo (accesso a `StreamSession`, MediaMTX client, Redis PID/owner). Le VM overflow sono quindi **worker Sidekiq Rails**, non demoni ffmpeg nudi — almeno nella fase 1.
|
||||
|
||||
---
|
||||
|
||||
## 4. Colli di bottiglia (cosa può rompersi comunque)
|
||||
|
||||
Anche con overflow infinito di CPU, restano limiti. Ordine di probabilità/impatto:
|
||||
|
||||
### 4.1 MediaMTX sul dedicated (alto)
|
||||
|
||||
- N publisher RTMP + N reader (HLS siti + N ffmpeg che rileggono lo stesso path).
|
||||
- Ogni relay in più è un **reader** su MediaMTX (RTMP pull o HLS).
|
||||
- Sintomi: path `ready` flaky, HLS a scatti sul sito, relay che rientrano in fallback HLS, CPU MediaMTX alta anche con relay scarichi.
|
||||
|
||||
**Mitigazione futura (fuori da questo overflow):** secondo nodo MediaMTX o sharding path; per ora monitorare `readers`, CPU `mediamtx`, bitrate aggregato.
|
||||
|
||||
### 4.2 Uplink Internet del dedicated (alto)
|
||||
|
||||
- Telefoni → dedicated (ingresso RTMP).
|
||||
- Dedicated → YouTube **se** i relay restano locali.
|
||||
- Con overflow Cloud: il dedicated manda **copia dello stream** verso le VM (pull dalle VM = traffico **uscita** dal dedicated verso Hetzner Cloud), poi le VM mandano a YouTube.
|
||||
|
||||
Attenzione al verso:
|
||||
|
||||
| Dove gira il relay | Traffico tipico |
|
||||
|--------------------|-----------------|
|
||||
| Sul dedicated | In: telefoni. Out: N× verso YouTube |
|
||||
| Su Hetzner Cloud | Out dedicated → Cloud (intake). Out Cloud → YouTube |
|
||||
|
||||
Se l’uplink casa/datacenter verso Internet è stretto, spostare i relay sul Cloud **sposta** il carico out YouTube fuori dal dedicated, ma **aggiunge** out dedicated→Cloud (stesso ordine di grandezza del video).
|
||||
Vantaggio reale solo se:
|
||||
|
||||
- l’uplink del dedicated satura soprattutto per **CPU** (oggi sì), oppure
|
||||
- dedicated e Cloud sono in **stessa regione Hetzner con rete privata** (traffico interno economico/veloce) e l’uscita verso YouTube esce dalla rete Hetzner Cloud (migliore peering).
|
||||
|
||||
**Decisione di rete obbligatoria prima di implementare** (vedi §6).
|
||||
|
||||
### 4.3 Redis / lock owner (medio)
|
||||
|
||||
Oggi:
|
||||
|
||||
- `youtube_relay:pid:%s` — PID locale al worker
|
||||
- `youtube_relay:owner:%s` — `HOSTNAME` del worker
|
||||
- `running?` considera attivo un owner remoto se TTL > 30s
|
||||
|
||||
Con più worker:
|
||||
|
||||
- Due ensure concorrenti possono avviare due ffmpeg (debounce 5s aiuta poco cross-host).
|
||||
- `stop` da Rails manda job: deve raggiungere il worker **owner**, non un overflow a caso.
|
||||
- Kill del PID funziona solo sulla macchina owner (`/proc`).
|
||||
|
||||
Serve disciplina di coda / routing (§5.3).
|
||||
|
||||
### 4.4 Cold start VM (medio)
|
||||
|
||||
Creare una CPX/CCX Hetzner: ordine di **30–90+ secondi** (API + boot + pull image + Sidekiq ready).
|
||||
Se la 15ª live parte e non c’è capacità, la broadcast YouTube resta “in attesa” finché non c’è worker.
|
||||
|
||||
**Requisito prodotto:** overflow **a caldo** = pool minimo già acceso nei weekend / fasce note, non “da zero al fischio”.
|
||||
|
||||
### 4.5 YouTube / account / quote (basso–medio, esterno)
|
||||
|
||||
- Limiti API, token OAuth, ban temporanei, ingest RTMPS rifiutato.
|
||||
- Non risolvibili con più CPU; restano nel runbook ops esistente.
|
||||
|
||||
### 4.6 Post-process replay (basso in live, alto a fine giornata)
|
||||
|
||||
`UploadFromSession` / thumbnail competono su Sidekiq e disco.
|
||||
I worker overflow **non** devono prendere coda `default` di merge se sono pensati solo per relay — altrimenti spegnendo le VM a fine picco uccidete job a metà.
|
||||
|
||||
### 4.7 Costo e idle (operativo)
|
||||
|
||||
VM dimenticate accese = bolletta. Serve TTL, scale-in aggressivo, alert “overflow acceso da >Xh con 0 relay”.
|
||||
|
||||
---
|
||||
|
||||
## 5. Complessità di implementazione
|
||||
|
||||
### 5.1 Rete: come le VM leggono MediaMTX
|
||||
|
||||
Opzioni (sceglierne **una** in fase 0):
|
||||
|
||||
| Opzione | Pro | Contro |
|
||||
|---------|-----|--------|
|
||||
| **A. Stessa location Hetzner + private network** (dedicated Robot + Cloud nella stessa rete) | Bassa latenza, traffico interno, modello pulito | Richiede che il dedicated sia (o diventi) in ecosistema Hetzner collegabile |
|
||||
| **B. WireGuard/Tailscale** dedicated ↔ Cloud | Funziona anche da Proxmox casa | Ops tunnel, MTU, failover; latenza |
|
||||
| **C. Esporre RTMP/HLS intake in lettura** (IP allowlist VM overflow) | Semplice | Superficie attacco; non esporre senza auth/TLS dove possibile |
|
||||
| **D. Relay locale che pusha a Cloud** | Dedicated controlla egress | Doppia hop, più pezzi |
|
||||
|
||||
Raccomandazione di piano: **A se il dedicated è/andrà su Hetzner; altrimenti B**. Evitare C in chiaro su WAN.
|
||||
|
||||
Variabili da prevedere sulle VM:
|
||||
|
||||
- `MEDIAMTX_INTERNAL_RTMP_URL` → URL raggiungibile dalle VM (non `rtmp://mediamtx:1935` Docker-locale)
|
||||
- `MEDIAMTX_HLS_URL` → idem
|
||||
- MediaMTX API (`:9997`) raggiungibile in privato per `intake_available?` / PublisherOnline, **mai** su Internet aperto
|
||||
|
||||
### 5.2 Accesso Postgres e segreti
|
||||
|
||||
Worker Sidekiq Rails oggi necessitano:
|
||||
|
||||
- `DATABASE_URL` (o replica read + job che non scrivono — irrealistico all’inizio)
|
||||
- `REDIS_URL`
|
||||
- `SECRET_KEY_BASE` / credenziali YouTube via DB
|
||||
- stessi env di produzione (ridotti)
|
||||
|
||||
Complessità: ogni overflow è un **nodo fidato** della rete app.
|
||||
Immagine Docker identica a `sidekiq` prod, env da secret manager o file scp/cloud-init, **nessuna** porta Rails pubblica sulle VM.
|
||||
|
||||
### 5.3 Code Sidekiq e affinità relay
|
||||
|
||||
Stato oggi: ensure/stop via job + processo locale con owner in Redis.
|
||||
|
||||
Per multi-host serve esplicitare:
|
||||
|
||||
1. **Coda dedicata** `youtube_relay` (solo worker con `YOUTUBE_RELAY_WORKER=1`).
|
||||
2. **Cap locale**: ogni worker ha `RELAY_MAX_CONCURRENT` (es. cores−1).
|
||||
3. **Scheduling**:
|
||||
- Fase 1 (manuale): overflow sempre in ascolto sulla coda; Sidekiq distribuisce i job; `ensure_on_worker!` no-op se a cap (re-enqueue o lascia ad altro worker).
|
||||
- Fase 2: controller di capacità che alza/abbassa N VM in base a `relay_attivi` e profondità coda.
|
||||
4. **Stop**: job `YoutubeRelayStopJob` deve eseguire **solo sull’owner** (Sidekiq unique + check `owner == HOSTNAME`, altrimenti requeue con delay breve).
|
||||
|
||||
Nota: i PID in Redis non sono globalmente killabili — il modello owner è già abbozzato; va reso robusto cross-host.
|
||||
|
||||
### 5.4 Scale-out / scale-in (lifecycle VM)
|
||||
|
||||
```text
|
||||
Metriche → Decisione → hcloud CLI/API → cloud-init → Sidekiq ready → in coda
|
||||
↘ fallisce → alert ops, non spegnere dedicated
|
||||
```
|
||||
|
||||
**Scale-out trigger (esempi):**
|
||||
|
||||
- `youtube_relay` depth > 0 per >60s **oppure**
|
||||
- `relay_attivi_local >= RELAY_SOFT_CAP` **e** nuove sessioni YT in `connecting|live`
|
||||
|
||||
**Scale-in trigger:**
|
||||
|
||||
- `relay_attivi` sulle VM overflow = 0 per >15–30 min **e**
|
||||
- fuori dalla finestra “weekend caldo” (opzionale)
|
||||
|
||||
**Warm pool:** sabato 8:00 → min 1–2 VM già up; domenica 23:00 → min 0.
|
||||
|
||||
### 5.5 Idempotenza e split-brain
|
||||
|
||||
Scenario da evitare: dedicated e overflow avviano entrambi ffmpeg sulla stessa `stream_key` → YouTube flappa.
|
||||
|
||||
Mitigazioni:
|
||||
|
||||
- Lock Redis con fencing token / TTL heartbeat rinnovato dal processo owner ogni N secondi
|
||||
- Watchdog (`YoutubeIngestWatchdogJob`) deve rispettare owner remoto (già parzialmente così via TTL)
|
||||
- Un solo writer della broadcast activate
|
||||
|
||||
### 5.6 Observability
|
||||
|
||||
Senza metriche l’overflow è cieco. Minimo:
|
||||
|
||||
| Metrica | Dove |
|
||||
|---------|------|
|
||||
| N relay ffmpeg vivi (local / per host) | Redis + `pgrep` / heartbeat |
|
||||
| CPU MediaMTX, CPU Sidekiq | node exporter / docker stats |
|
||||
| Profondità coda `youtube_relay` | Sidekiq API |
|
||||
| Bitrate / errori ffmpeg per session | log già in `log/youtube_relay_*.log` |
|
||||
| VM overflow accese, € stimati | tag Hetzner + cron report |
|
||||
| Latenza intake Cloud (RTT dedicated↔VM) | check periodico |
|
||||
|
||||
Alert ntfy esistenti: estendere con `relay_overflow_saturated`, `mediamtx_cpu_high`, `overflow_vm_orphan`.
|
||||
|
||||
### 5.7 Sicurezza
|
||||
|
||||
- Firewall: VM → solo Redis, Postgres, MediaMTX (porte interne), YouTube egress 443
|
||||
- Nessun SSH password; chiave o console Hetzner
|
||||
- Stream key YouTube solo in DB/encrypted; non in user-data in chiaro se evitabile
|
||||
- Image aggiornata; spegnimento = destroy, non “pausare” dischi dimenticati per mesi
|
||||
|
||||
---
|
||||
|
||||
## 6. Decisioni aperte (bloccare prima del codice)
|
||||
|
||||
| # | Domanda | Impatto |
|
||||
|---|---------|---------|
|
||||
| D1 | Dedicated resta su LAN casa/Proxmox o migra/affianca Hetzner Robot? | Sceglie rete A vs B (§5.1) |
|
||||
| D2 | Soft cap relay sul dedicated (es. 4? 6?) | Quando scatta overflow |
|
||||
| D3 | Warm pool fisso weekend vs solo reactive | Cold start vs costo |
|
||||
| D4 | Tipo VM (CPX shared vs CCX dedicated CPU) | €/live e jitter AAC |
|
||||
| D5 | Stessa immagine `sidekiq` vs worker slim | Tempo boot e superficie |
|
||||
| D6 | Chi orchestra le VM? (script cron su dedicated, Terraform, small Go/Ruby service) | Ops ownership |
|
||||
|
||||
Finché D1 non è chiusa, non stimare bandwitdh né SLA del picco.
|
||||
|
||||
---
|
||||
|
||||
## 7. Dimensionamento indicativo
|
||||
|
||||
Ipotesi: 720p, remux copy A/V — **~0.15–0.4 vCPU** medi per relay (picchi su analyze/reconnect).
|
||||
|
||||
| Target live YT | Dedicated (relay) | Overflow tipico | Note |
|
||||
|----------------|-------------------|-----------------|------|
|
||||
| ≤6 | Tutto locale | 0 | Situazione attuale “comoda” se box dedicato ai relay |
|
||||
| 10–15 | soft cap 4–6 | 1× VM 4–8 vCPU | Warm consigliato in giornata evento |
|
||||
| 20–30 | soft cap 4–6 | 2–4× VM | MediaMTX e uplink diventano critici |
|
||||
| 50+ | softire anche ingest | N VM + 2° MediaMTX | Fuori scope overflow-only |
|
||||
|
||||
Questi numeri vanno **calibati** con un load test (N sessioni fake + ffmpeg contro slate MediaMTX) prima di promettere SLA commerciali.
|
||||
|
||||
---
|
||||
|
||||
## 8. Piano a fasi (pronti a reagire, non a over-build)
|
||||
|
||||
### Fase 0 — Misura e soglie (1–2 giorni ops)
|
||||
|
||||
- [ ] Dashboard/manuale: N live YT, CPU `sidekiq`, CPU `mediamtx`, uplink
|
||||
- [ ] Definire `RELAY_SOFT_CAP` empirico sul dedicated
|
||||
- [ ] Chiudere **D1** (rete)
|
||||
- [ ] Load test controllato: 8–12 relay solo slate (senza telefoni) per vedere dove rompe
|
||||
|
||||
**Criterio go/no-go overflow:** saturazione CPU Sidekiq/ffmpeg **prima** di MediaMTX/uplink. Se saturano prima rete o MediaMTX, overflow Cloud non basta.
|
||||
|
||||
### Fase 1 — Overflow manuale “a caldo” (MVP ops)
|
||||
|
||||
- [ ] Snapshot/immagine Sidekiq relay-ready su Hetzner Cloud
|
||||
- [ ] cloud-init: env, WireGuard o private net, `docker compose up sidekiq`
|
||||
- [ ] Runbook: “accendi 1 VM”, verifica `YoutubeRelay` su HOSTNAME nuovo, “spegni”
|
||||
- [ ] Soft cap sul dedicated: nuovi ensure preferiscono coda se locali pieni (anche patch minima)
|
||||
- [ ] Alert se VM overflow up da >3h con 0 owner keys
|
||||
|
||||
**Reazione a incident:** operatore umano accende/spegne; nessun autoscaler ancora.
|
||||
|
||||
### Fase 2 — Routing coda robusto
|
||||
|
||||
- [ ] Coda `youtube_relay` isolata
|
||||
- [ ] Heartbeat owner Redis
|
||||
- [ ] Stop/ensure sticky all’owner
|
||||
- [ ] Cap per worker + requeue
|
||||
|
||||
### Fase 3 — Autoscaling controllato
|
||||
|
||||
- [ ] Job/cron: se saturazione → `hcloud server create` fino a `OVERFLOW_MAX`
|
||||
- [ ] Scale-in solo se idle e fuori warm window
|
||||
- [ ] Budget mensile + kill switch
|
||||
|
||||
### Fase 4 — Solo se necessario
|
||||
|
||||
- Secondo MediaMTX / sharding
|
||||
- ~~Audio copy se l’encoder telefono è già YouTube-compatibile (meno CPU)~~ → fatto (app AAC 48k mono + relay `-c:a copy`)
|
||||
- Dedicated più grosso come baseline (spesso più economico dell’overflow cronico ogni weekend)
|
||||
|
||||
---
|
||||
|
||||
## 9. Runbook di reazione (anche prima dell’autoscaler)
|
||||
|
||||
### Sintomo: YouTube “in programma” / niente video con molte live
|
||||
|
||||
1. `docker stats` / CPU Sidekiq sul dedicated
|
||||
2. Contare relay: log `[YoutubeRelay] started` vs sessioni `platform=youtube` live
|
||||
3. Se CPU ~100% e MediaMTX ok → **accendere overflow** (Fase 1) o abbassare nuove live YT
|
||||
4. Se MediaMTX alto / path non ready → overflow **non** aiuta; ridurre reader o spostare HLS, non aggiungere pull Cloud ciechi
|
||||
|
||||
### Sintomo: live YT che si spezza solo sulle VM Cloud
|
||||
|
||||
1. RTT e packet loss dedicated↔VM
|
||||
2. Verificare che intake usi RTMP interno, non HLS pubblico via Internet
|
||||
3. Controllare doppio owner / due ffmpeg sulla stessa key
|
||||
|
||||
### Sintomo: bolletta Cloud anomala
|
||||
|
||||
1. Lista server con label `matchlivetv-overflow`
|
||||
2. Destroy idle
|
||||
3. Abbassare warm pool
|
||||
|
||||
### Sintomo: job replay morti dopo scale-in
|
||||
|
||||
1. Verificare che overflow **non** consumi coda `default` / `recordings`
|
||||
2. Riprocessare dead set Sidekiq
|
||||
|
||||
---
|
||||
|
||||
## 10. Criteri di successo
|
||||
|
||||
| Criterio | Misura |
|
||||
|----------|--------|
|
||||
| Picco gestito | ≥15 live YT contemporanee stabili in test |
|
||||
| Continuità prodotto | Drop telefono → slate YouTube ancora attiva (invariato) |
|
||||
| Costo | Overflow acceso solo in finestre picco; €/h documentato |
|
||||
| Ops | Runbook Fase 1 eseguibile in <10 min da operatore |
|
||||
| Non regressione | Live solo-sito (`matchlivetv`) e replay invariati |
|
||||
|
||||
---
|
||||
|
||||
## 11. Riepilogo esecutivo
|
||||
|
||||
Il pezzo che manca oltre ~10 live YouTube è **CPU dei relay**, non l’idea MediaMTX.
|
||||
Hetzner Cloud è la leva economica giusta se:
|
||||
|
||||
1. scalate **solo** i worker `YoutubeRelay`,
|
||||
2. tenete MediaMTX caldo sul dedicated,
|
||||
3. risolvete **rete privata** dedicated↔Cloud,
|
||||
4. partite da **warm pool + runbook manuale**, poi automate.
|
||||
|
||||
Il rischio principale non è “creare una VM”, è **leggere N volte MediaMTX e saturare uplink/MediaMTX** mentre pensate di aver risolto solo la CPU. La Fase 0 (misura) decide se l’overflow è sufficiente o se serve anche più ingest/baseline hardware.
|
||||
)
|
||||
+5
-1
@@ -14,7 +14,9 @@ YOUTUBE_MOCK_STREAM_KEY=mock-stream-key
|
||||
|
||||
PRIVACY_CONTROLLER_NAME=Emiliano Frascaro
|
||||
PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI)
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlive.it
|
||||
PRIVACY_CONTACT_EMAIL=privacy@matchlivetv.it
|
||||
# Opzionale: email dedicata di supporto (default = info@matchlivetv.it)
|
||||
# SUPPORT_CONTACT_EMAIL=info@matchlivetv.it
|
||||
MAILER_FROM=Match Live TV <noreply@matchlivetv.it>
|
||||
PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
|
||||
@@ -24,6 +26,8 @@ PASSWORD_RESET_EXPIRY_HOURS=2
|
||||
# Stripe (test: sk_test_... / price_... da Dashboard modalità Test)
|
||||
STRIPE_SECRET_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
# Price ID dei prezzi LANCIO da addebitare:
|
||||
# Light yearly €59 · Light monthly €7,90 · Full yearly €199 · Full monthly €24,90
|
||||
# Un price per prodotto e intervallo (Dashboard → Prodotto → Prezzi)
|
||||
STRIPE_PREMIUM_LIGHT_MONTHLY_PRICE_ID=
|
||||
STRIPE_PREMIUM_LIGHT_YEARLY_PRICE_ID=
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user