Compare commits

..
Author SHA1 Message Date
eminuxandCursor e436f5ece4 Aggiunge pagamento con bonifico e importo concordato per società.
Il piano si attiva solo dopo la conferma admin; Stripe resta a listino se non c'è un prezzo commerciale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:43 +02:00
eminuxandCursor 1e4e0560f1 Allinea le spec alla policy password e prepara i piani di test.
Senza questo la suite locale fallisce sulla complessità password e sui piani mancanti.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 23:20:31 +02:00
eminuxandCursor 841a6a15be Avvia i relay YouTube overflow dal Sidekiq home e passa STREAM/HCLOUD in produzione.
Così un CPX nuovo non richiede worker Docker a mano, l'agent è nel cloud-init e gli E2E restano non in elenco.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-18 08:57:56 +02:00
eminuxandCursor dd94bf7e66 Riprova le GET Hetzner su errori SSL transitori e aggiunge i loghi con nome.
Durante il wait del CPX l'API chiudeva la connessione TLS; i tre PNG servono le varianti dark/white del marchio.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-17 22:56:46 +02:00
eminuxandCursor baa6283a15 Mantiene la copertina YouTube se il telefono cade e avvia ffmpeg sul nodo, non via SSH.
Il relay HLS→RTMPS resta sul worker/agent del nodo assegnato, così tre dirette contemporanee restano in onda sul sito e sul canale della società senza schermo nero.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 20:41:46 +02:00
eminux bfb3115a64 Merge branch 'main' into collaudo
Porta in collaudo l'invito trasmissione con email automatica.
2026-08-15 20:02:40 +02:00
eminuxandCursor db8b14ba4e Snellisci l'invito a trasmettere e invia l'email automatica.
Il form invito è sulla pagina squadra, genera il link e manda la mail; in caso di errore SMTP resta la copia manuale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 20:02:25 +02:00
eminux 46244fe066 Merge branch 'main' into collaudo
Porta su collaudo i prezzi di lancio, la pagina Funzionalità e i badge store.
2026-08-14 23:09:20 +02:00
eminuxandCursor 3ed70b6999 Aggiorna marketing: prezzi lancio, Funzionalità narrativa e link store.
Allinea i piani Light/Full ai prezzi di lancio, ripensa /funzionalita per vendere il prodotto e aggiunge i badge App Store/Play nella home.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 23:00:17 +02:00
eminuxandCursor b05b1acd7a Usa l'hostname pubblico per l'ingest RTMP di collaudo.
I telefoni in 4G non raggiungono l'IP LAN; la NAT sulla 11935 espone collaudo.matchlivetv.it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 23:26:34 +02:00
177 changed files with 6050 additions and 591 deletions
@@ -2,6 +2,7 @@ module Admin
class BillingController < BaseController class BillingController < BaseController
def index def index
@pending_payments = pending_scope.recent @pending_payments = pending_scope.recent
@pending_transfers = transfer_scope
@completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40) @completed_payments = Billing::Payment.with_invoice_pdf.includes(:club, :invoice).recent.limit(40)
@filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present? @filter_club = Club.find_by(id: params[:club_id]) if params[:club_id].present?
@clubs = Club.order(:name) @clubs = Club.order(:name)
@@ -18,6 +19,25 @@ module Admin
alert: e.message alert: e.message
end end
def confirm_transfer
order = Billing::TransferOrder.find(params[:id])
Billing::ConfirmBankTransfer.call(
order: order,
admin: current_admin_account,
pdf: params[:pdf]
)
redirect_to admin_billing_path(club_id: order.club_id),
notice: t("admin.flash.transfer_confirmed", plan: order.plan.name, club: order.club.name)
rescue Billing::ConfirmBankTransfer::Error, Billing::AttachPaymentInvoice::Error, Billing::IssueInvoice::Error => e
redirect_to admin_billing_path, alert: e.message
end
def cancel_transfer
order = Billing::TransferOrder.find(params[:id])
order.cancel!
redirect_to admin_billing_path(club_id: order.club_id), notice: t("admin.flash.transfer_cancelled")
end
private private
def pending_scope def pending_scope
@@ -26,6 +46,12 @@ module Admin
scope scope
end end
def transfer_scope
scope = Billing::TransferOrder.awaiting_payment.includes(:club, :requested_by_user)
scope = scope.where(club_id: params[:club_id]) if params[:club_id].present?
scope
end
def billing_redirect_params(payment) def billing_redirect_params(payment)
{ club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact { club_id: payment.club_id, anchor: "payment-#{payment.id}" }.compact
end end
@@ -1,9 +1,9 @@
module Admin module Admin
class ClubsController < BaseController class ClubsController < BaseController
before_action :set_club, only: %i[show grant_comped revoke_comped] before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote]
def index def index
@clubs = Club.includes(:teams, subscription: %i[plan admin_comped_by]) @clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by])
.order(:name) .order(:name)
end end
@@ -11,6 +11,7 @@ module Admin
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") @subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" } @plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name) @teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
end end
def grant_comped def grant_comped
@@ -32,6 +33,32 @@ module Admin
redirect_back_or_club alert: e.message redirect_back_or_club alert: e.message
end end
def set_quote
quote = Billing::SetClubQuote.upsert(
club: @club,
plan_slug: params.require(:plan_slug),
interval: params[:interval],
amount_euros: params[:amount_euros],
note: params[:note],
admin: current_admin_account
)
redirect_back_or_club notice: t(
"admin.flash.quote_saved",
club: @club.name,
plan: quote.plan.name,
amount: quote.formatted_amount
)
rescue Billing::SetClubQuote::Error, ActionController::ParameterMissing => e
redirect_back_or_club alert: e.message
end
def revoke_quote
Billing::SetClubQuote.revoke(club: @club, admin: current_admin_account)
redirect_back_or_club notice: t("admin.flash.quote_revoked", club: @club.name)
rescue Billing::SetClubQuote::Error => e
redirect_back_or_club alert: e.message
end
private private
def set_club def set_club
@@ -4,11 +4,20 @@ module MediamtxPlayback
private private
def mediamtx_paths_index def mediamtx_paths_index
@mediamtx_paths_index ||= Mediamtx::Client.new.list_paths.index_by { |i| i["name"] } mediamtx_paths_index_for_url(MatchLiveTv.mediamtx_api_url)
end
def mediamtx_paths_index_for(session)
mediamtx_paths_index_for_url(session.mediamtx_api_base_url)
end
def mediamtx_paths_index_for_url(api_url)
@mediamtx_paths_by_origin ||= {}
@mediamtx_paths_by_origin[api_url] ||= Mediamtx::Client.new(base_url: api_url).list_paths.index_by { |i| i["name"] }
end end
def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session)) def mediamtx_path_info(session, path_name: mediamtx_playback_path_name(session))
mediamtx_paths_index[path_name] mediamtx_paths_index_for(session)[path_name]
end end
def mediamtx_playback_path_name(session) def mediamtx_playback_path_name(session)
@@ -28,7 +37,7 @@ module MediamtxPlayback
end end
def mediamtx_publisher_online?(session) def mediamtx_publisher_online?(session)
info = mediamtx_paths_index[session.mediamtx_path_name] info = mediamtx_paths_index_for(session)[session.mediamtx_path_name]
info && info["online"] info && info["online"]
end end
end end
@@ -20,7 +20,8 @@ class HlsProxyController < ActionController::Base
def proxy_mediamtx_path(upstream_path) def proxy_mediamtx_path(upstream_path)
return head :not_found if upstream_path.blank? return head :not_found if upstream_path.blank?
upstream = "#{MatchLiveTv.mediamtx_hls_url}/#{upstream_path}" origin = hls_origin_for(upstream_path)
upstream = "#{origin}/#{upstream_path}"
upstream = "#{upstream}?#{request.query_string}" if request.query_string.present? upstream = "#{upstream}?#{request.query_string}" if request.query_string.present?
cookie = request.headers["Cookie"].presence || "cookieCheck=1" cookie = request.headers["Cookie"].presence || "cookieCheck=1"
@@ -30,7 +31,7 @@ class HlsProxyController < ActionController::Base
if response.status.in?([301, 302, 307, 308]) if response.status.in?([301, 302, 307, 308])
location = response.headers["location"].to_s location = response.headers["location"].to_s
cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie cookie = cookie_from_set_header(response.headers["set-cookie"]).presence || cookie
upstream = resolve_upstream_url(location) upstream = resolve_upstream_url(location, origin)
next next
end end
@@ -43,10 +44,21 @@ class HlsProxyController < ActionController::Base
head :bad_gateway head :bad_gateway
end end
def resolve_upstream_url(location) def hls_origin_for(upstream_path)
session_id = upstream_path[/\Alive\/match_([0-9a-f-]{36})/i, 1]
if session_id
session = StreamSession.find_by(id: session_id)
node_hls = session&.stream_node&.internal_hls_url.presence
return node_hls.sub(%r{/$}, "") if node_hls.present?
end
MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "")
end
def resolve_upstream_url(location, origin)
return location if location.start_with?("http://", "https://") return location if location.start_with?("http://", "https://")
base = MatchLiveTv.mediamtx_hls_url.sub(%r{/$}, "") base = origin.to_s.sub(%r{/$}, "")
location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}" location.start_with?("/") ? "#{base}#{location}" : "#{base}/#{location}"
end end
@@ -11,11 +11,15 @@ module Public
@club.assign_attributes(billing_profile_params) @club.assign_attributes(billing_profile_params)
if @club.save(context: :billing_profile) if @club.save(context: :billing_profile)
if premium_checkout_return_params.present? && @club.billing_profile_complete? if premium_checkout_return_params.present? && @club.billing_profile_complete?
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
else
redirect_to public_club_checkout_path( redirect_to public_club_checkout_path(
@club, @club,
plan: premium_checkout_return_params[:plan], plan: premium_checkout_return_params[:plan],
interval: premium_checkout_return_params[:interval] interval: premium_checkout_return_params[:interval]
), notice: t("flash.club_billing.profile_saved_proceed_payment") ), notice: t("flash.club_billing.profile_saved_proceed_payment")
end
else else
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated") redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.profile_updated")
end end
@@ -42,6 +46,29 @@ module Public
redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message) redirect_to public_club_billing_path(@club), alert: t("flash.club_billing.stripe_error", message: e.message)
end end
def request_bank_transfer
if @club.subscription&.admin_comped?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.comped_change_denied")
return
end
unless @club.billing_profile_complete?
redirect_to public_club_billing_profile_path(@club, plan: params[:plan], interval: params[:interval]),
alert: t("flash.clubs.complete_billing_first")
return
end
Billing::RequestBankTransfer.call(
club: @club,
user: current_user,
plan_slug: params[:plan],
interval: params[:interval]
)
redirect_to public_club_billing_path(@club), notice: t("flash.club_billing.bank_transfer_requested")
rescue Billing::RequestBankTransfer::Error, ArgumentError => e
redirect_to public_club_billing_path(@club), alert: e.message
end
def download_invoice def download_invoice
invoice = @club.billing_invoices.find(params[:invoice_id]) invoice = @club.billing_invoices.find(params[:invoice_id])
unless invoice.pdf.attached? unless invoice.pdf.attached?
@@ -82,6 +82,8 @@ module Public
@entitlements = @team.entitlements @entitlements = @team.entitlements
@plans = Plan.ordered @plans = Plan.ordered
@payments = @club.billing_payments.recent.includes(:invoice).limit(50) @payments = @club.billing_payments.recent.includes(:invoice).limit(50)
@quote = @club.active_billing_quote
@pending_transfer = @club.billing_transfer_orders.awaiting_payment.first
end end
def checkout def checkout
@@ -92,6 +94,12 @@ module Public
return return
end end
if @club.active_billing_quote.present?
redirect_to public_club_billing_path(@club),
alert: t("flash.club_billing.quote_checkout_denied")
return
end
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured") redirect_to public_club_billing_path(@club), alert: t("flash.clubs.stripe_not_configured")
return return
@@ -58,7 +58,7 @@ module Public
def invite def invite
require_club_owner_for_team!(@team) require_club_owner_for_team!(@team)
@entitlements = @team.entitlements redirect_to public_team_details_path(@team, anchor: "invita-trasmissione")
end end
def assign_self_staff def assign_self_staff
@@ -87,18 +87,32 @@ module Public
email = params[:email]&.downcase&.strip email = params[:email]&.downcase&.strip
Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind) Teams::StaffEmailValidator.assert_available!(team: @team, email: email, staff_kind: staff_kind)
token = TeamInvitation.generate_token token = TeamInvitation.generate_token
@team.team_invitations.create!( invitation = @team.team_invitations.create!(
email: email, email: email,
token_digest: Digest::SHA256.hexdigest(token), token_digest: Digest::SHA256.hexdigest(token),
role: "member", role: "member",
staff_kind: staff_kind, staff_kind: staff_kind,
expires_at: 7.days.from_now expires_at: 7.days.from_now
) )
@invite_url = join_public_invitation_url(token: token) invite_url = public_invitation_url(token: token)
flash.now[:notice] = t("flash.teams.invite_link_generated") flash[:invite_url] = invite_url
render :invite
begin
Teams::InvitationMailer.transmission_invite(
team: @team,
invitation: invitation,
invite_url: invite_url,
invited_by: current_user
).deliver_now
flash[:notice] = t("flash.teams.invite_email_sent", email: email)
rescue StandardError => e
Rails.logger.error("[invite_email] #{e.class}: #{e.message}")
flash[:alert] = t("flash.teams.invite_email_failed", email: email)
end
redirect_to public_team_details_path(@team, anchor: "invito-generato")
rescue Teams::EntitlementError, Teams::StaffAssignmentError => e rescue Teams::EntitlementError, Teams::StaffAssignmentError => e
redirect_to public_team_invite_path(@team), alert: e.message redirect_to public_team_details_path(@team, anchor: "invita-trasmissione"), alert: e.message
end end
def remove_member def remove_member
+53 -6
View File
@@ -1,6 +1,6 @@
module Public module Public
module BillingHelper module BillingHelper
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil) def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil, club: nil)
if target_plan.slug == "free" if target_plan.slug == "free"
return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free" return { kind: :current, label: I18n.t("billing.actions.current_plan") } if current_slug == "free"
return { kind: :none } if stripe_subscription_active return { kind: :none } if stripe_subscription_active
@@ -8,17 +8,37 @@ module Public
return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") } return { kind: :contact, label: I18n.t("billing.actions.contact_for_free") }
end end
quote = club&.active_billing_quote
if quote
if quote.plan_slug == target_plan.slug
return { kind: :quoted, plan: target_plan, quote: quote, intervals: [quote.billing_interval] }
end
return { kind: :quoted_other, label: I18n.t("billing.bank_transfer.quoted_other") }
end
intervals = bank_transfer_intervals_for(target_plan)
unless MatchLiveTv.stripe_enabled? unless MatchLiveTv.stripe_enabled?
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") } return { kind: :disabled, label: I18n.t("billing.actions.stripe_not_configured") }
end end
intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug) stripe_intervals = Billing::Stripe::PriceCatalog.available_intervals(plan_slug: target_plan.slug)
return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") } if intervals.empty? if stripe_intervals.empty?
if MatchLiveTv.bank_transfer_configured?
return { kind: :bank_only, plan: target_plan, intervals: intervals }
end
return { kind: :disabled, label: I18n.t("billing.actions.stripe_prices_not_configured") }
end
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending? if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
other_intervals = intervals - [active_interval] other_intervals = stripe_intervals - [active_interval]
if other_intervals.empty? if other_intervals.empty?
label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}" label = "#{I18n.t('billing.actions.current_plan')}#{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
return { kind: :current, label: label } return { kind: :current, label: label }
@@ -35,12 +55,12 @@ module Public
end end
if current_slug == "free" || !stripe_subscription_active if current_slug == "free" || !stripe_subscription_active
{ kind: :checkout_options, plan: target_plan, intervals: intervals, subscription: subscription } { kind: :checkout_options, plan: target_plan, intervals: stripe_intervals.presence || intervals, subscription: subscription }
else else
{ {
kind: :change_options, kind: :change_options,
plan: target_plan, plan: target_plan,
intervals: intervals, intervals: stripe_intervals,
current_slug: current_slug, current_slug: current_slug,
current_interval: active_interval, current_interval: active_interval,
subscription: subscription subscription: subscription
@@ -57,6 +77,10 @@ module Public
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind) Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
end 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) def billing_plan_change_info_lines(subscription: nil)
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription) Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
end end
@@ -92,5 +116,28 @@ module Public
I18n.t("billing.actions.profile_missing", fields: missing.join(", ")) I18n.t("billing.actions.profile_missing", fields: missing.join(", "))
end end
def bank_transfer_intervals_for(plan)
Billing::Stripe::PriceCatalog.catalog_intervals(plan_slug: plan.slug)
end
def bank_transfer_price_label(plan, interval, quote: nil)
if quote&.matches?(plan.slug, interval)
quote.price_label
else
Billing::Stripe::PriceCatalog.format_interval_price(
Billing::Stripe::PriceCatalog.amount_cents(plan_slug: plan.slug, interval: interval),
interval
)
end
end
def show_bank_transfer_for?(club:, plan:, interval:, quote: nil, pending_transfer: nil)
return false unless club.present? && plan.slug != "free"
return false unless MatchLiveTv.bank_transfer_configured?
return false if quote && !quote.matches?(plan.slug, interval)
return false if pending_transfer&.awaiting_payment?
true
end
end end
end end
@@ -8,10 +8,35 @@ class ExpireEndedSubscriptionsJob
Subscription.where(cancel_at_period_end: true) Subscription.where(cancel_at_period_end: true)
.where("current_period_end <= ?", Time.current) .where("current_period_end <= ?", Time.current)
.where.not(plan_id: free_plan.id) .where.not(plan_id: free_plan.id)
.where(admin_comped: false)
.find_each do |sub| .find_each do |sub|
Billing::Stripe::FinalizeSubscription.call(club: sub.club) expire!(sub)
rescue StandardError => e rescue StandardError => e
Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}") Rails.logger.warn("[ExpireEndedSubscriptions] club=#{sub.club_id} #{e.message}")
end end
end end
private
def expire!(sub)
if sub.stripe_subscription_id.present?
Billing::Stripe::FinalizeSubscription.call(club: sub.club)
else
Billing::AssignPlan.call(
club: sub.club,
plan_slug: "free",
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
stripe_schedule_id: nil,
current_period_start: nil,
current_period_end: nil,
cancel_at_period_end: false,
billing_interval: nil,
pending_plan_id: nil,
pending_billing_interval: nil
}
)
end
end
end end
+1 -1
View File
@@ -1,4 +1,4 @@
# Avvia/riavvia il relay YouTube solo sui worker con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay). # Avvia/riavvia il relay YouTube sul worker del nodo (coda youtube_relay_<slug>).
class YoutubeRelayEnsureJob < ApplicationJob class YoutubeRelayEnsureJob < ApplicationJob
queue_as Streams::YoutubeRelay::QUEUE queue_as Streams::YoutubeRelay::QUEUE
+2 -1
View File
@@ -17,6 +17,7 @@ class YoutubeRelayStopJob < ApplicationJob
return unless result == :wrong_host return unless result == :wrong_host
return if attempts >= 30 return if attempts >= 30
self.class.set(wait: 2.seconds).perform_later(session_id, attempts + 1) self.class.set(wait: 2.seconds, queue: Streams::YoutubeRelay.queue_for(session))
.perform_later(session_id, attempts + 1)
end end
end end
@@ -0,0 +1,35 @@
module Billing
class BankTransferMailer < ApplicationMailer
def instructions
@order = params[:order]
@club = @order.club
@iban = MatchLiveTv.bank_transfer_iban
@holder = MatchLiveTv.bank_transfer_account_holder
@bank_name = MatchLiveTv.bank_transfer_bank_name
@bic = MatchLiveTv.bank_transfer_bic
@proof_email = MatchLiveTv.bank_transfer_proof_email
mail(
to: recipient_email,
subject: t("mailers.bank_transfer.instructions.subject", plan: @order.plan.name)
)
end
def plan_activated
@order = params[:order]
@club = @order.club
@subscription = @club.subscription
mail(
to: recipient_email,
subject: t("mailers.bank_transfer.plan_activated.subject", plan: @order.plan.name)
)
end
private
def recipient_email
@club.billing_email.presence || @club.owner&.email
end
end
end
+26 -6
View File
@@ -5,17 +5,37 @@ module Billing
@club = @invoice.club @club = @invoice.club
I18n.with_locale(I18n.locale) do I18n.with_locale(I18n.locale) do
prefix = t("mailers.invoice.attachment_prefix") attach_invoice_pdf!
attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
mime_type: "application/pdf",
content: @invoice.pdf.download
}
mail( mail(
to: @club.billing_email, to: @club.billing_email,
subject: t("mailers.invoice.subject", number: @invoice.display_number) subject: t("mailers.invoice.subject", number: @invoice.display_number)
) )
end end
end end
def plan_activated_with_invoice
@invoice = params[:invoice]
@club = @invoice.club
I18n.with_locale(I18n.locale) do
attach_invoice_pdf!
mail(
to: @club.billing_email,
subject: t("mailers.invoice_activated.subject",
plan: @club.subscription&.plan&.name || @invoice.display_number,
number: @invoice.display_number)
)
end
end
private
def attach_invoice_pdf!
prefix = t("mailers.invoice.attachment_prefix")
attachments["#{prefix}-#{@invoice.number.parameterize}.pdf"] = {
mime_type: "application/pdf",
content: @invoice.pdf.download
}
end
end end
end end
@@ -0,0 +1,25 @@
module Teams
class InvitationMailer < ApplicationMailer
default from: -> { MatchLiveTv.mail_from }
def transmission_invite(team:, invitation:, invite_url:, invited_by:)
@team = team
@club = team.club
@invitation = invitation
@invite_url = invite_url
@invited_by = invited_by
@expires_on = I18n.l(invitation.expires_at.to_date, format: :long)
I18n.with_locale(I18n.locale) do
mail(
to: invitation.email,
subject: t(
"mailers.transmission_invite.subject",
team: @team.name,
club: @club.name
)
)
end
end
end
end
+34
View File
@@ -0,0 +1,34 @@
module Billing
class ClubQuote < ApplicationRecord
self.table_name = "billing_club_quotes"
PLAN_SLUGS = %w[premium_light premium_full].freeze
INTERVALS = Billing::Stripe::PriceCatalog::INTERVALS
belongs_to :club
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
validates :plan_slug, inclusion: { in: PLAN_SLUGS }
validates :billing_interval, inclusion: { in: INTERVALS }
validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true
scope :active, -> { where(active: true) }
def matches?(plan_slug, interval)
self.plan_slug == plan_slug.to_s && billing_interval == interval.to_s
end
def plan
Plan[plan_slug]
end
def formatted_amount
Billing::Stripe::PriceCatalog.format_eur(amount_cents)
end
def price_label
Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, billing_interval)
end
end
end
+10 -2
View File
@@ -2,14 +2,17 @@ module Billing
class Payment < ApplicationRecord class Payment < ApplicationRecord
self.table_name = "billing_payments" self.table_name = "billing_payments"
STATUSES = %w[paid failed refunded].freeze STATUSES = %w[pending paid failed refunded].freeze
PROVIDERS = %w[stripe bank_transfer].freeze
belongs_to :club belongs_to :club
has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify
has_one :transfer_order, class_name: "Billing::TransferOrder", foreign_key: :billing_payment_id, dependent: :nullify
validates :amount_cents, numericality: { greater_than: 0 } validates :amount_cents, numericality: { greater_than: 0 }
validates :currency, presence: true validates :currency, presence: true
validates :status, inclusion: { in: STATUSES } validates :status, inclusion: { in: STATUSES }
validates :provider, inclusion: { in: PROVIDERS }
validates :stripe_invoice_id, uniqueness: true, allow_nil: true validates :stripe_invoice_id, uniqueness: true, allow_nil: true
scope :recent, -> { order(paid_at: :desc, created_at: :desc) } scope :recent, -> { order(paid_at: :desc, created_at: :desc) }
@@ -62,7 +65,12 @@ module Billing
end end
def display_status def display_status
{ "paid" => "Pagato", "failed" => "Non riuscito", "refunded" => "Rimborsato" }[status] || status {
"pending" => "In attesa di bonifico",
"paid" => "Pagato",
"failed" => "Non riuscito",
"refunded" => "Rimborsato"
}[status] || status
end end
def invoice_for_display def invoice_for_display
@@ -0,0 +1,67 @@
module Billing
class TransferOrder < ApplicationRecord
self.table_name = "billing_transfer_orders"
KINDS = %w[list_price commercial_quote].freeze
STATUSES = %w[awaiting_payment paid cancelled].freeze
PLAN_SLUGS = ClubQuote::PLAN_SLUGS
INTERVALS = ClubQuote::INTERVALS
belongs_to :club
belongs_to :billing_club_quote, class_name: "Billing::ClubQuote", optional: true
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
belongs_to :requested_by_user, class_name: "User", optional: true
belongs_to :confirmed_by_admin, class_name: "AdminAccount", optional: true
validates :plan_slug, inclusion: { in: PLAN_SLUGS }
validates :billing_interval, inclusion: { in: INTERVALS }
validates :amount_cents, numericality: { greater_than: 0 }
validates :kind, inclusion: { in: KINDS }
validates :status, inclusion: { in: STATUSES }
validates :reference_code, presence: true, uniqueness: true
validates :currency, presence: true
scope :awaiting_payment, -> { where(status: "awaiting_payment").order(created_at: :desc) }
scope :recent, -> { order(created_at: :desc) }
def awaiting_payment?
status == "awaiting_payment"
end
def paid?
status == "paid"
end
def quoted?
kind == "commercial_quote"
end
def plan
Plan[plan_slug]
end
def formatted_amount
format("%.2f €", amount_cents / 100.0)
end
def price_label
Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, billing_interval)
end
def payment_causal
"MLTV #{reference_code} #{club.name}".truncate(140, omission: "")
end
def cancel!
return self unless awaiting_payment?
transaction do
update!(status: "cancelled", cancelled_at: Time.current)
if billing_payment&.status == "pending"
billing_payment.update!(status: "failed")
end
end
self
end
end
end
+11
View File
@@ -7,8 +7,11 @@ class Club < ApplicationRecord
has_many :teams, dependent: :destroy has_many :teams, dependent: :destroy
has_one :youtube_credential, dependent: :destroy has_one :youtube_credential, dependent: :destroy
has_one :subscription, dependent: :destroy has_one :subscription, dependent: :destroy
has_one :billing_quote, -> { where(active: true) }, class_name: "Billing::ClubQuote", inverse_of: :club
has_many :billing_quotes, class_name: "Billing::ClubQuote", dependent: :destroy, inverse_of: :club
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy
has_many :billing_transfer_orders, class_name: "Billing::TransferOrder", dependent: :destroy
validates :name, presence: true validates :name, presence: true
validates :sport, presence: true validates :sport, presence: true
@@ -23,6 +26,14 @@ class Club < ApplicationRecord
club_memberships.exists?(user: user, role: "owner") club_memberships.exists?(user: user, role: "owner")
end end
def active_billing_quote
billing_quote
end
def pending_transfer_order
billing_transfer_orders.awaiting_payment.first
end
private private
def branding_parent def branding_parent
+16
View File
@@ -35,4 +35,20 @@ class StreamNode < ApplicationRecord
def allocatable? def allocatable?
status == "ready" && free_slots.positive? status == "ready" && free_slots.positive?
end end
# Agent ffmpeg sul CPX (cloud-init :9100). Home Sidekiq lo chiama senza worker Docker per nodo.
def relay_agent_url
return unless role == "cloud"
ip = (metadata || {})["public_ip"].presence
if ip.blank?
host = URI.parse(api_base_url.to_s).host
ip = host if host.present? && host != "localhost"
end
return if ip.blank? || ip == "127.0.0.1"
"http://#{ip}:#{ENV.fetch("STREAM_NODE_AGENT_PORT", "9100")}"
rescue URI::InvalidURIError
nil
end
end end
+4
View File
@@ -35,4 +35,8 @@ class Subscription < ApplicationRecord
def admin_comped? def admin_comped?
admin_comped admin_comped
end end
def bank_transfer?
premium? && stripe_subscription_id.blank? && !admin_comped? && current_period_end.present?
end
end end
+16 -2
View File
@@ -4,19 +4,33 @@ class YoutubeCredential < ApplicationRecord
attr_encrypted :access_token, attr_encrypted :access_token,
key: :encryption_key, key: :encryption_key,
attribute: "access_token_encrypted", attribute: "access_token_encrypted",
mode: :single_iv_salt mode: :single_iv_and_salt,
algorithm: "aes-256-cbc",
iv: :encryption_iv
attr_encrypted :refresh_token, attr_encrypted :refresh_token,
key: :encryption_key, key: :encryption_key,
attribute: "refresh_token_encrypted", attribute: "refresh_token_encrypted",
mode: :single_iv_salt mode: :single_iv_and_salt,
algorithm: "aes-256-cbc",
iv: :encryption_iv
def expired? def expired?
expires_at.present? && expires_at < Time.current expires_at.present? && expires_at < Time.current
end end
def usable?
refresh_token.present?
rescue ArgumentError, OpenSSL::Cipher::CipherError, NoMethodError
false
end
private private
def encryption_key def encryption_key
Rails.application.secret_key_base[0, 32] Rails.application.secret_key_base[0, 32]
end end
def encryption_iv
encryption_key[0, 16]
end
end end
@@ -23,6 +23,7 @@ module Billing
raise Error, "Piano non valido" unless @plan_slug.in?(VALID_PLANS) raise Error, "Piano non valido" unless @plan_slug.in?(VALID_PLANS)
release_stripe_schedule! release_stripe_schedule!
cancel_awaiting_transfers!
AssignPlan.call( AssignPlan.call(
club: @club, club: @club,
@@ -97,5 +98,9 @@ module Billing
ensure ensure
sub&.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil) sub&.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil)
end end
def cancel_awaiting_transfers!
@club.billing_transfer_orders.awaiting_payment.find_each(&:cancel!)
end
end end
end end
@@ -2,13 +2,14 @@ module Billing
class AttachPaymentInvoice class AttachPaymentInvoice
class Error < StandardError; end class Error < StandardError; end
def self.call(payment:, pdf:) def self.call(payment:, pdf:, mailer_action: :invoice_pdf)
new(payment: payment, pdf: pdf).call new(payment: payment, pdf: pdf, mailer_action: mailer_action).call
end end
def initialize(payment:, pdf:) def initialize(payment:, pdf:, mailer_action: :invoice_pdf)
@payment = payment @payment = payment
@pdf = pdf @pdf = pdf
@mailer_action = mailer_action
end end
def call def call
@@ -17,7 +18,7 @@ module Billing
club = @payment.club club = @payment.club
invoice = @payment.invoice || build_invoice!(club) invoice = @payment.invoice || build_invoice!(club)
IssueInvoice.call(invoice: invoice, pdf: @pdf) IssueInvoice.call(invoice: invoice, pdf: @pdf, mailer_action: @mailer_action)
end end
private private
@@ -0,0 +1,102 @@
module Billing
class ConfirmBankTransfer
class Error < StandardError; end
def self.call(order:, admin:, pdf: nil)
new(order: order, admin: admin, pdf: pdf).call
end
def initialize(order:, admin:, pdf: nil)
@order = order
@admin = admin
@pdf = pdf
end
def call
raise Error, "Bonifico già gestito" unless @order.awaiting_payment?
club = @order.club
payment = @order.billing_payment
raise Error, "Pagamento collegato mancante" if payment.blank?
ApplicationRecord.transaction do
cancel_existing_stripe!(club.subscription)
period_start, period_end = period_bounds(club.subscription)
AssignPlan.call(
club: club,
plan_slug: @order.plan_slug,
status: "active",
stripe_attrs: {
stripe_subscription_id: nil,
stripe_schedule_id: nil,
pending_plan_id: nil,
pending_billing_interval: nil,
billing_interval: @order.billing_interval,
current_period_start: period_start,
current_period_end: period_end,
cancel_at_period_end: true,
admin_comped: false,
admin_comped_reason: nil,
admin_comped_at: nil,
admin_comped_by_id: nil
}
)
payment.update!(status: "paid", paid_at: Time.current, provider: "bank_transfer")
@order.update!(
status: "paid",
confirmed_by_admin: @admin,
confirmed_at: Time.current
)
end
deliver_activation!(payment)
@order.reload
end
private
def period_bounds(subscription)
start_at = Time.current
if subscription&.premium? &&
!subscription.admin_comped? &&
subscription.plan.slug == @order.plan_slug &&
subscription.current_period_end.present? &&
subscription.current_period_end > Time.current
start_at = subscription.current_period_end
end
end_at = @order.billing_interval == "yearly" ? start_at.advance(years: 1) : start_at.advance(months: 1)
[start_at, end_at]
end
def cancel_existing_stripe!(subscription)
return if subscription.blank? || subscription.stripe_subscription_id.blank?
return unless MatchLiveTv.stripe_enabled?
::Stripe::Subscription.cancel(subscription.stripe_subscription_id)
rescue ::Stripe::InvalidRequestError => e
Rails.logger.warn("[BankTransfer] stripe cancel club=#{subscription.club_id} #{e.message}")
end
def deliver_activation!(payment)
if pdf_present?
AttachPaymentInvoice.call(
payment: payment.reload,
pdf: @pdf,
mailer_action: :plan_activated_with_invoice
)
else
BankTransferMailer.with(order: @order.reload).plan_activated.deliver_now
end
end
def pdf_present?
return false if @pdf.blank?
return @pdf.present? unless @pdf.respond_to?(:tempfile)
@pdf.original_filename.present?
end
end
end
@@ -0,0 +1,26 @@
module Billing
class EuroAmount
class Error < StandardError; end
def self.to_cents(value)
raw = value.to_s.strip
raise Error, "Indica l'importo in euro" if raw.blank?
normalized = raw.gsub(/\s+/, "")
if normalized.match?(/\A\d{1,3}(\.\d{3})*,\d{1,2}\z/)
normalized = normalized.gsub(".", "").tr(",", ".")
elsif normalized.match?(/\A\d+,\d{1,2}\z/)
normalized = normalized.tr(",", ".")
elsif normalized.match?(/\A\d{1,3}(,\d{3})*\.\d{1,2}\z/)
normalized = normalized.gsub(",", "")
end
raise Error, "Importo non valido" unless normalized.match?(/\A\d+(\.\d{1,2})?\z/)
cents = (BigDecimal(normalized) * 100).round
raise Error, "L'importo deve essere maggiore di zero" unless cents.positive?
cents.to_i
end
end
end
@@ -2,13 +2,17 @@ module Billing
class IssueInvoice class IssueInvoice
class Error < StandardError; end class Error < StandardError; end
def self.call(invoice:, pdf: nil) MAILER_ACTIONS = %i[invoice_pdf plan_activated_with_invoice].freeze
new(invoice: invoice, pdf: pdf).call
def self.call(invoice:, pdf: nil, mailer_action: :invoice_pdf)
new(invoice: invoice, pdf: pdf, mailer_action: mailer_action).call
end end
def initialize(invoice:, pdf: nil) def initialize(invoice:, pdf: nil, mailer_action: :invoice_pdf)
@invoice = invoice @invoice = invoice
@pdf = pdf @pdf = pdf
@mailer_action = mailer_action.to_sym
raise Error, "Azione email non valida" unless MAILER_ACTIONS.include?(@mailer_action)
end end
def call def call
@@ -22,7 +26,7 @@ module Billing
@invoice.update!(status: "issued") @invoice.update!(status: "issued")
Billing::InvoiceMailer.with(invoice: @invoice).invoice_pdf.deliver_now Billing::InvoiceMailer.with(invoice: @invoice).public_send(@mailer_action).deliver_now
@invoice.update!(status: "sent", emailed_at: Time.current) @invoice.update!(status: "sent", emailed_at: Time.current)
@invoice @invoice
@@ -5,9 +5,13 @@ module Billing
AMOUNT_HINTS = { AMOUNT_HINTS = {
500 => %w[premium_light monthly], 500 => %w[premium_light monthly],
790 => %w[premium_light monthly],
4000 => %w[premium_light yearly], 4000 => %w[premium_light yearly],
5900 => %w[premium_light yearly],
2000 => %w[premium_full monthly], 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 }.freeze
class << self class << self
@@ -0,0 +1,95 @@
module Billing
class RequestBankTransfer
class Error < StandardError; end
PLAN_SLUGS = %w[premium_light premium_full].freeze
def self.call(club:, user:, plan_slug:, interval:)
new(club: club, user: user, plan_slug: plan_slug, interval: interval).call
end
def initialize(club:, user:, plan_slug:, interval:)
@club = club
@user = user
@plan_slug = plan_slug.to_s
@interval = interval
end
def call
raise Error, "Bonifico non configurato sul server" unless MatchLiveTv.bank_transfer_configured?
raise Error, "Piano non valido" unless @plan_slug.in?(PLAN_SLUGS)
raise Error, "Completa i dati di fatturazione prima di richiedere il bonifico." unless @club.billing_profile_complete?
raise Error, "Il piano è un abbonamento omaggio. Contatta il supporto per passarlo a pagamento." if @club.subscription&.admin_comped?
@interval = Billing::Stripe::PriceCatalog.normalize_interval(@interval)
quote = @club.active_billing_quote
amount_cents, kind = resolve_amount(quote)
existing = @club.billing_transfer_orders.awaiting_payment.first
if existing
if existing.plan_slug == @plan_slug && existing.billing_interval == @interval && existing.amount_cents == amount_cents
BankTransferMailer.with(order: existing).instructions.deliver_now
return existing
end
existing.cancel!
end
order = nil
ApplicationRecord.transaction do
payment = @club.billing_payments.create!(
provider: "bank_transfer",
amount_cents: amount_cents,
currency: "eur",
status: "pending",
plan_slug: @plan_slug,
description: payment_description(kind, amount_cents)
)
order = @club.billing_transfer_orders.create!(
billing_club_quote: kind == "commercial_quote" ? quote : nil,
billing_payment: payment,
plan_slug: @plan_slug,
billing_interval: @interval,
amount_cents: amount_cents,
currency: "eur",
kind: kind,
status: "awaiting_payment",
reference_code: generate_reference_code,
requested_by_user: @user
)
end
BankTransferMailer.with(order: order).instructions.deliver_now
order
end
private
def resolve_amount(quote)
if quote
unless quote.matches?(@plan_slug, @interval)
raise Error, "Per questa società è attivo un prezzo concordato su #{quote.plan.name} (#{quote.price_label})."
end
return [quote.amount_cents, "commercial_quote"]
end
[Billing::Stripe::PriceCatalog.amount_cents(plan_slug: @plan_slug, interval: @interval), "list_price"]
end
def payment_description(kind, amount_cents)
label = Billing::Stripe::PriceCatalog.format_interval_price(amount_cents, @interval)
suffix = kind == "commercial_quote" ? "prezzo concordato, bonifico" : "bonifico"
"#{Plan[@plan_slug].name}#{label} (#{suffix})"
end
def generate_reference_code
8.times do
code = "MLTV-#{SecureRandom.alphanumeric(6).upcase}"
return code unless TransferOrder.exists?(reference_code: code)
end
raise Error, "Impossibile generare il riferimento del bonifico"
end
end
end
@@ -0,0 +1,74 @@
module Billing
class SetClubQuote
class Error < StandardError; end
PLAN_SLUGS = %w[premium_light premium_full].freeze
def self.upsert(club:, plan_slug:, interval:, amount_euros:, note:, admin:)
new(club: club, plan_slug: plan_slug, interval: interval, amount_euros: amount_euros, note: note, admin: admin).upsert
end
def self.revoke(club:, admin:)
new(club: club, admin: admin).revoke
end
def initialize(club:, plan_slug: nil, interval: nil, amount_euros: nil, note: nil, admin: nil)
@club = club
@plan_slug = plan_slug.to_s.presence
@interval = interval
@amount_euros = amount_euros
@note = note.to_s.strip.presence
@admin = admin
end
def upsert
raise Error, "Piano non valido" unless @plan_slug.in?(PLAN_SLUGS)
interval = Billing::Stripe::PriceCatalog.normalize_interval(@interval)
amount_cents = EuroAmount.to_cents(@amount_euros)
quote = @club.billing_quotes.active.first || @club.billing_quotes.build
ApplicationRecord.transaction do
quote.assign_attributes(
plan_slug: @plan_slug,
billing_interval: interval,
amount_cents: amount_cents,
currency: "eur",
note: @note,
active: true,
created_by_admin: @admin || quote.created_by_admin
)
quote.save!
cancel_incompatible_orders!(quote)
end
quote
rescue EuroAmount::Error, ArgumentError => e
raise Error, e.message
end
def revoke
quote = @club.active_billing_quote
raise Error, "Nessun prezzo concordato attivo" if quote.blank?
ApplicationRecord.transaction do
quote.update!(active: false)
@club.billing_transfer_orders.awaiting_payment.where(kind: "commercial_quote").find_each(&:cancel!)
end
quote
end
private
def cancel_incompatible_orders!(quote)
@club.billing_transfer_orders.awaiting_payment.find_each do |order|
next if order.plan_slug == quote.plan_slug &&
order.billing_interval == quote.billing_interval &&
order.amount_cents == quote.amount_cents
order.cancel!
end
end
end
end
@@ -23,10 +23,11 @@ module Billing
def button_label(plan:, interval:, kind:) def button_label(plan:, interval:, kind:)
price = PriceCatalog.label(plan_slug: plan.slug, interval: interval) price = PriceCatalog.label(plan_slug: plan.slug, interval: interval)
name = I18n.t("pages.plans.cta_name.#{plan.slug}", default: plan.name)
if kind == :checkout 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 else
I18n.t("billing.messages.switch_button", plan: plan.name, price: price) I18n.t("billing.messages.switch_button", plan: name, price: price)
end end
end end
@@ -4,9 +4,16 @@ module Billing
INTERVALS = %w[monthly yearly].freeze INTERVALS = %w[monthly yearly].freeze
DEFAULT_INTERVAL = "yearly" DEFAULT_INTERVAL = "yearly"
PRICING = { # Importi in centesimi (EUR). Il yearly è il prezzo lancio addebitato.
"premium_light" => { "yearly" => "€40/anno", "monthly" => "€5/mese" }, AMOUNTS = {
"premium_full" => { "yearly" => "€200/anno", "monthly" => "€20/mese" } "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 }.freeze
class << self class << self
@@ -30,7 +37,23 @@ module Billing
end end
def label(plan_slug:, interval:) 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 end
def available_intervals(plan_slug:) def available_intervals(plan_slug:)
@@ -52,8 +75,45 @@ module Billing
nil nil
end 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
def catalog_intervals(plan_slug:)
INTERVALS.select { |interval| AMOUNTS.dig(plan_slug.to_s, interval, :charge).present? }
end
def amount_cents(plan_slug:, interval:)
interval = normalize_interval(interval)
cents = charge_cents(plan_slug, interval)
raise ArgumentError, "Prezzo listino non disponibile per #{plan_slug} (#{interval})" if cents.blank?
cents
end
def format_interval_price(cents, interval)
return nil if cents.blank?
I18n.t("billing.prices.#{interval}", amount: format_eur(cents))
end
private 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 price_id_for(plan_slug, interval) def price_id_for(plan_slug, interval)
case [plan_slug, interval] case [plan_slug, interval]
when %w[premium_light monthly] when %w[premium_light monthly]
+2 -3
View File
@@ -22,8 +22,7 @@ module Mediamtx
def create_path(session) def create_path(session)
path = session.mediamtx_path_name path = session.mediamtx_path_name
# record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe # record: false finché non c'è publisher — con alwaysAvailable MediaMTX registrerebbe
# solo la slate (schermo nero) in pausa/attesa. # solo la slate in pausa/attesa. Slate resta accesa anche su YouTube (copertina se l'app cade).
# YouTube: niente slate sul path camera (maschera il video al relay ffmpeg).
body = recording_body(session, enabled: false).merge( body = recording_body(session, enabled: false).merge(
source: "publisher", source: "publisher",
overridePublisher: true overridePublisher: true
@@ -76,7 +75,7 @@ module Mediamtx
end end
# Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline. # Slate alwaysAvailable: copertina sullo stesso path quando il telefono è offline.
# Disattivare quando il publisher è in onda; riattivare in pausa/disconnessione. # Resta accesa anche con publisher in onda (MediaMTX usa il publisher se presente).
def set_always_available(session, enabled:) def set_always_available(session, enabled:)
path = session.mediamtx_path_name path = session.mediamtx_path_name
return true if always_available_remembered?(path, enabled: enabled) return true if always_available_remembered?(path, enabled: enabled)
@@ -19,7 +19,6 @@ module Mediamtx
if @session.paused? if @session.paused?
# RTMP ancora connesso in pausa: non forzare live/reconnect. # RTMP ancora connesso in pausa: non forzare live/reconnect.
else else
enable_live_path_once!(@session)
if @session.may_go_live? if @session.may_go_live?
@session.go_live! @session.go_live!
@session.reload @session.reload
@@ -75,23 +74,10 @@ module Mediamtx
@session.update!(timeout_job_id: job) @session.update!(timeout_job_id: job)
end end
# Compatibilità con webhook / controller.
def self.schedule_youtube_pipeline!(session, force: false) def self.schedule_youtube_pipeline!(session, force: false)
Youtube::LivePipeline.schedule!(session, force: force) Youtube::LivePipeline.schedule!(session, force: force)
end end
def enable_live_path_once!(session)
return unless session.platform == "youtube"
key = format("youtube:slate_disabled:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 48.hours.to_i)
Client.for_session(session).set_always_available(session, enabled: false)
rescue Client::Error => e
redis.del(format("youtube:slate_disabled:%s", session.id))
Rails.logger.warn("[PublisherSync] disable slate session=#{session.id}: #{e.message}")
end
def restore_slate_path!(session) def restore_slate_path!(session)
return if session.platform == "matchlivetv" return if session.platform == "matchlivetv"
@@ -183,7 +169,7 @@ module Mediamtx
key = format("youtube_relay:sched:%s", session.id) key = format("youtube_relay:sched:%s", session.id)
return unless redis.set(key, "1", nx: true, ex: 10) return unless redis.set(key, "1", nx: true, ex: 10)
YoutubeRelayEnsureJob.set(queue: Streams::YoutubeRelay::QUEUE).perform_later(session.id) Streams::YoutubeRelay.enqueue_ensure!(session)
end end
def redis def redis
@@ -139,11 +139,20 @@ module Streams
end end
def get(path, params = {}) def get(path, params = {})
tries = 0
begin
response = conn.get(path) do |req| response = conn.get(path) do |req|
req.headers.update(auth_headers) req.headers.update(auth_headers)
req.params.update(params) req.params.update(params)
end end
unwrap!(response) unwrap!(response)
rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError
tries += 1
raise if tries >= 5
sleep 2
retry
end
end end
def post(path, body) def post(path, body)
@@ -77,9 +77,8 @@ module Streams
dns.upsert_a(hostname, ip) dns.upsert_a(hostname, ip)
simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"]) simulated = instance.raw.is_a?(Hash) && (instance.raw[:simulated] || instance.raw["simulated"])
api_base = simulated ? home.api_base_url : "http://#{private_ip}:9997" urls = urls_for_node(role: role, home: home, hostname: hostname, simulated: simulated,
internal_rtmp = simulated ? home.internal_rtmp_url : "rtmp://#{private_ip}:1935" private_ip: private_ip, public_ip: ip, use_node_hostname: use_node_hostname)
internal_hls = simulated ? home.internal_hls_url : "http://#{private_ip}:8888"
StreamNode.create!( StreamNode.create!(
slug: slug, slug: slug,
@@ -88,11 +87,11 @@ module Streams
status: "ready", status: "ready",
provider: provider_name_for(cloud, role: role), provider: provider_name_for(cloud, role: role),
provider_instance_id: instance.id, provider_instance_id: instance.id,
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url, rtmp_base_url: urls.fetch(:rtmp_base_url),
hls_base_url: use_node_hostname ? "https://#{hostname}/hls" : home.hls_base_url, hls_base_url: urls.fetch(:hls_base_url),
api_base_url: api_base, api_base_url: urls.fetch(:api_base_url),
internal_rtmp_url: internal_rtmp, internal_rtmp_url: urls.fetch(:internal_rtmp_url),
internal_hls_url: internal_hls, internal_hls_url: urls.fetch(:internal_hls_url),
max_publishers: max, max_publishers: max,
max_relays: max, max_relays: max,
last_health_at: Time.current, last_health_at: Time.current,
@@ -105,6 +104,39 @@ module Streams
) )
end end
def urls_for_node(role:, home:, hostname:, simulated:, private_ip:, public_ip: nil, use_node_hostname:)
if role == "lab" && ENV["MEDIAMTX_LAB_API_URL"].present?
return {
api_base_url: ENV.fetch("MEDIAMTX_LAB_API_URL"),
internal_rtmp_url: ENV.fetch("MEDIAMTX_LAB_INTERNAL_RTMP_URL", "rtmp://mediamtx_lab:1935"),
internal_hls_url: ENV.fetch("MEDIAMTX_LAB_HLS_URL", "http://mediamtx_lab:8888"),
rtmp_base_url: ENV.fetch("MEDIAMTX_LAB_RTMP_URL", "rtmp://127.0.0.1:11935"),
# HLS pubblico resta sul proxy del sito: il controller instrada al MediaMTX del nodo.
hls_base_url: home.hls_base_url
}
end
control_ip = control_ip_for(role: role, private_ip: private_ip, public_ip: public_ip)
{
api_base_url: simulated ? home.api_base_url : "http://#{control_ip}:9997",
internal_rtmp_url: simulated ? home.internal_rtmp_url : "rtmp://#{control_ip}:1935",
internal_hls_url: simulated ? home.internal_hls_url : "http://#{control_ip}:8888",
rtmp_base_url: use_node_hostname ? "rtmp://#{hostname}:1935" : home.rtmp_base_url,
# Player del sito: proxy Rails/edge. HTTPS diretto sul nodo arriva dopo TLS/Caddy.
hls_base_url: home.hls_base_url
}
end
# Senza WireGuard/network privata Rails deve parlare con l'IPv4 pubblico (API+HLS).
def control_ip_for(role:, private_ip:, public_ip:)
public_ip = public_ip.presence
return private_ip if role != "cloud" || public_ip.blank?
return public_ip if ENV["STREAM_CLOUD_PUBLIC_CONTROL"] == "1"
return public_ip if private_ip.blank? || private_ip == public_ip
private_ip
end
def next_slug(prefix) def next_slug(prefix)
used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug) used = StreamNode.where("slug LIKE ?", "#{prefix}-%").pluck(:slug)
n = 1 n = 1
+195 -26
View File
@@ -1,6 +1,10 @@
require "json"
require "net/http"
require "uri"
module Streams module Streams
# Relay verso YouTube: legge RTMP/HLS da MediaMTX e inoltra su RTMPS (-c copy). Nessun overlay. # Relay verso YouTube: legge HLS da MediaMTX e inoltra su RTMPS (-c copy).
# ffmpeg gira solo sui worker Sidekiq con YOUTUBE_RELAY_WORKER=1 (coda youtube_relay). # Sul nodo cloud ffmpeg è avviato dall'agent locale (immagine CPX) all'avvio diretta.
class YoutubeRelay class YoutubeRelay
class Error < StandardError; end class Error < StandardError; end
@@ -8,6 +12,7 @@ module Streams
OWNER_KEY = "youtube_relay:owner:%s" OWNER_KEY = "youtube_relay:owner:%s"
OWNED_SET = "youtube_relay:owned:%s" OWNED_SET = "youtube_relay:owned:%s"
QUEUE = :youtube_relay QUEUE = :youtube_relay
QUEUE_PREFIX = "youtube_relay"
class << self class << self
def worker? def worker?
@@ -18,18 +23,63 @@ module Streams
ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i ENV.fetch("RELAY_MAX_CONCURRENT", "4").to_i
end end
def local_node_slug
ENV["STREAM_NODE_SLUG"].presence || NodeRegistry::HOME_SLUG
end
def assigned_node_slug(session)
session.stream_node&.slug.presence || NodeRegistry::HOME_SLUG
end
def node_matches?(session)
assigned_node_slug(session) == local_node_slug
end
# Home può avviare ffmpeg sull'agent del CPX (niente worker Docker per nodo).
def can_process?(session)
return false unless worker?
return true if node_matches?(session)
cloud_home_dispatch?(session)
end
def cloud_home_dispatch?(session)
local_node_slug == NodeRegistry::HOME_SLUG &&
session.stream_node&.role == "cloud" &&
session.stream_node.relay_agent_url.present?
end
def queue_for(session)
node = session.stream_node
if node&.role == "cloud" && node.relay_agent_url.present?
return "#{QUEUE_PREFIX}_cloud"
end
"#{QUEUE_PREFIX}_#{assigned_node_slug(session)}"
end
def enqueue_ensure!(session, wait: nil)
opts = { queue: queue_for(session) }
opts[:wait] = wait if wait
YoutubeRelayEnsureJob.set(**opts).perform_later(session.id)
end
def enqueue_stop!(session)
YoutubeRelayStopJob.set(queue: queue_for(session)).perform_later(session.id)
end
def start(session) def start(session)
return unless worker? return enqueue_ensure!(session) unless can_process?(session)
start_on_worker!(session) start_on_worker!(session)
end end
# Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg. # Non cancella owner/pid qui: solo il worker owner deve killare ffmpeg.
def stop(session) def stop(session)
if worker? && owner_is_local?(session.id) if worker? && owner_is_local?(session.id) && can_process?(session)
stop_on_worker!(session) stop_on_worker!(session)
else else
YoutubeRelayStopJob.set(queue: QUEUE).perform_later(session.id) enqueue_stop!(session)
end end
true true
end end
@@ -41,7 +91,7 @@ module Streams
return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30 return false if pid.blank? && owner.present? && owner != worker_id && redis.ttl(format(OWNER_KEY, session_id)) <= 30
return false if pid.blank? return false if pid.blank?
return process_alive?(pid) if owner.blank? || owner == worker_id return process_alive?(pid, session_id: session_id) if owner.blank? || owner == worker_id
# Relay su altro host: attivo se lock owner ancora fresco. # Relay su altro host: attivo se lock owner ancora fresco.
redis.ttl(format(OWNER_KEY, session_id)) > 30 redis.ttl(format(OWNER_KEY, session_id)) > 30
@@ -52,15 +102,22 @@ module Streams
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
if worker? if can_process?(session)
ensure_on_worker!(session) ensure_on_worker!(session)
else else
YoutubeRelayEnsureJob.set(queue: QUEUE).perform_later(session.id) enqueue_ensure!(session)
end end
end end
def ensure_on_worker!(session) def ensure_on_worker!(session)
return :not_worker unless worker? return :not_worker unless worker?
unless can_process?(session)
enqueue_ensure!(session, wait: 2.seconds)
Rails.logger.info(
"[YoutubeRelay] wrong_node local=#{local_node_slug} assigned=#{assigned_node_slug(session)} session=#{session.id}"
)
return :wrong_host
end
return unless session.platform == "youtube" return unless session.platform == "youtube"
return if session.terminal? return if session.terminal?
return if session.stream_key.blank? return if session.stream_key.blank?
@@ -68,7 +125,7 @@ module Streams
return unless intake_available?(session) return unless intake_available?(session)
pid = pid_for(session.id) pid = pid_for(session.id)
if pid.present? && !process_alive?(pid.to_i) if pid.present? && !process_alive?(pid.to_i, session_id: session.id)
clear_local_ownership(session.id) clear_local_ownership(session.id)
end end
@@ -77,9 +134,9 @@ module Streams
return :already_running return :already_running
end end
if at_capacity? if at_capacity_for?(session)
YoutubeRelayEnsureJob.set(wait: 5.seconds, queue: QUEUE).perform_later(session.id) enqueue_ensure!(session, wait: 5.seconds)
Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} session=#{session.id} requeue") Rails.logger.info("[YoutubeRelay] at capacity worker=#{worker_id} node=#{local_node_slug} session=#{session.id} requeue")
return :at_capacity return :at_capacity
end end
@@ -91,6 +148,7 @@ module Streams
:started :started
rescue Error => e rescue Error => e
Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}") Rails.logger.warn("[YoutubeRelay] ensure_on_worker session=#{session.id}: #{e.message}")
enqueue_ensure!(session, wait: 5.seconds) unless session.terminal?
:error :error
end end
@@ -109,7 +167,7 @@ module Streams
return :noop return :noop
end end
terminate_pid(pid) terminate_pid(pid, session_id: session.id)
clear_local_ownership(session.id) clear_local_ownership(session.id)
Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}") Rails.logger.info("[YoutubeRelay] stopped pid=#{pid} session=#{session.id} worker=#{worker_id}")
:stopped :stopped
@@ -123,6 +181,13 @@ module Streams
local_owned_count >= max_concurrent local_owned_count >= max_concurrent
end end
# I relay sull'agent CPX non consumano ffmpeg locale: non applicare il cap del worker home.
def at_capacity_for?(session)
return false if cloud_home_dispatch?(session)
at_capacity?
end
def owner_is_local?(session_id) def owner_is_local?(session_id)
owner = redis.get(format(OWNER_KEY, session_id)) owner = redis.get(format(OWNER_KEY, session_id))
owner.blank? || owner == worker_id owner.blank? || owner == worker_id
@@ -140,26 +205,94 @@ module Streams
stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id) stop_on_worker!(session) if pid_for(session.id).present? && owner_is_local?(session.id)
# Due EnsureJob concorrenti (concurrency Sidekiq > 1) non devono spawnare due ffmpeg.
unless redis.set(format("youtube_relay:startlock:%s", session.id), worker_id, nx: true, ex: 20)
return pid_for(session.id).to_i
end
log_path = log_file(session) log_path = log_file(session)
FileUtils.mkdir_p(File.dirname(log_path)) FileUtils.mkdir_p(File.dirname(log_path))
intake_source = mediamtx_intake_source(session) intake_source = mediamtx_intake_source(session)
output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}" output = "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
pid = Process.spawn( pid = if agent_dispatch?(session)
*youtube_ffmpeg_args(intake_source, output), start_via_agent!(session)
%i[out err] => log_path, else
pgroup: true spawn_ffmpeg!(youtube_ffmpeg_args(intake_source, output), log_path)
) end
Process.detach(pid)
store_pid(session.id, pid) store_pid(session.id, pid)
claim_ownership!(session.id) claim_ownership!(session.id)
Rails.logger.info("[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(":")} worker=#{worker_id}") Rails.logger.info(
"[YoutubeRelay] started pid=#{pid} session=#{session.id} intake=#{intake_source.join(':')} " \
"worker=#{worker_id} agent=#{agent_base_url(session) || '-'}"
)
schedule_youtube_activate(session) schedule_youtube_activate(session)
pid pid
rescue Errno::ENOENT => e rescue Errno::ENOENT => e
raise Error, "ffmpeg non disponibile: #{e.message}" raise Error, "ffmpeg non disponibile: #{e.message}"
end end
def agent_base_url(session)
return if session.blank?
ENV["STREAM_NODE_RELAY_AGENT_URL"].presence || session.stream_node&.relay_agent_url
end
def agent_dispatch?(session)
agent_base_url(session).present?
end
def relay_agent_secret
ENV["STREAM_NODE_AGENT_SECRET"].presence || "mediamtx_webhook_dev_secret"
end
def spawn_ffmpeg!(args, log_path)
pid = Process.spawn(*args, %i[out err] => log_path, pgroup: true)
Process.detach(pid)
pid
end
def start_via_agent!(session)
payload = {
"session_id" => session.id,
"path" => session.mediamtx_path_name,
"rtmps" => "rtmps://a.rtmps.youtube.com/live2/#{session.stream_key}"
}
res = agent_request(Net::HTTP::Post, "/relays", payload, session: session)
pid = res["pid"].to_i
raise Error, "relay agent spawn failed: #{res.inspect}" if pid <= 0
File.write(
log_file(session),
"agent=#{agent_base_url(session)} pid=#{pid} path=#{session.mediamtx_path_name}\n"
)
pid
end
def agent_request(http_class, path, payload = nil, session: nil, session_id: nil)
session ||= StreamSession.find_by(id: session_id) if session_id.present?
base = agent_base_url(session)
raise Error, "relay agent URL mancante" if base.blank?
uri = URI.parse("#{base.chomp('/')}#{path}")
http = Net::HTTP.new(uri.host, uri.port)
http.open_timeout = 5
http.read_timeout = 10
req = http_class.new(uri)
req["Authorization"] = "Bearer #{relay_agent_secret}" if relay_agent_secret.present?
req["Content-Type"] = "application/json"
req.body = JSON.generate(payload) if payload
res = http.request(req)
body = res.body.present? ? JSON.parse(res.body) : {}
unless res.is_a?(Net::HTTPSuccess)
raise Error, "relay agent #{path} HTTP #{res.code} #{body.inspect}"
end
body
rescue JSON::ParserError => e
raise Error, "relay agent JSON: #{e.message}"
end
# Remux verso YouTube: video+audio copy (AAC già da app/slate a 48k mono). # 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. # HLS (ADTS) → FLV richiede -bsf:a aac_adtstoasc; RTMP ha già ASC in FLV tags.
def youtube_ffmpeg_args(intake_source, output) def youtube_ffmpeg_args(intake_source, output)
@@ -179,6 +312,9 @@ module Streams
end end
common_head + [ common_head + [
"-reconnect", "1",
"-reconnect_streamed", "1",
"-reconnect_delay_max", "2",
"-rw_timeout", "15000000", "-rw_timeout", "15000000",
"-live_start_index", "-1", "-live_start_index", "-1",
"-i", url, "-i", url,
@@ -190,14 +326,29 @@ module Streams
end end
def mediamtx_intake_source(session) def mediamtx_intake_source(session)
base = session.mediamtx_internal_rtmp_url rtmp_base, hls_base = intake_bases(session)
if Mediamtx::PublisherOnline.active?(session) if Mediamtx::PublisherOnline.active?(session)
return [:rtmp, "#{base.chomp('/')}/#{session.mediamtx_path_name}"] return [:rtmp, "#{rtmp_base.chomp('/')}/#{session.mediamtx_path_name}"]
end end
path = session.mediamtx_path_name path = session.mediamtx_path_name
hls = session.mediamtx_internal_hls_url.chomp("/") [:hls, "#{hls_base.chomp('/')}/#{path}/index.m3u8"]
[:hls, "#{hls}/#{path}/index.m3u8"] end
# Sul nodo assegnato ffmpeg legge MediaMTX in loopback (o hostname Docker locale).
def intake_bases(session)
if agent_dispatch?(session) && (node_matches?(session) || cloud_home_dispatch?(session))
return ["rtmp://127.0.0.1:1935", "http://127.0.0.1:8888"]
end
if node_matches?(session)
[
ENV["STREAM_NODE_LOCAL_RTMP_URL"].presence || session.mediamtx_internal_rtmp_url,
ENV["STREAM_NODE_LOCAL_HLS_URL"].presence || session.mediamtx_internal_hls_url
]
else
[session.mediamtx_internal_rtmp_url, session.mediamtx_internal_hls_url]
end
end end
def intake_available?(session) def intake_available?(session)
@@ -257,7 +408,10 @@ module Streams
redis.get(format(REDIS_KEY, session_id)) redis.get(format(REDIS_KEY, session_id))
end end
def process_alive?(pid) def process_alive?(pid, session_id: nil)
session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
return agent_session_running?(session) if session && agent_dispatch?(session)
stat = File.read("/proc/#{pid.to_i}/stat") stat = File.read("/proc/#{pid.to_i}/stat")
return false if stat.split[2] == "Z" return false if stat.split[2] == "Z"
@@ -267,12 +421,27 @@ module Streams
false false
end end
def terminate_pid(pid) def agent_session_running?(session)
res = agent_request(Net::HTTP::Get, "/relays/#{session.id}", session: session)
res["running"] == true
rescue Error
false
end
def terminate_pid(pid, session_id: nil)
session = session_id.present? ? StreamSession.find_by(id: session_id) : nil
if session && agent_dispatch?(session)
agent_request(Net::HTTP::Delete, "/relays/#{session_id}", session: session)
return
end
Process.kill("TERM", -pid.to_i) Process.kill("TERM", -pid.to_i)
sleep 0.5 sleep 0.5
rescue Errno::ESRCH rescue Errno::ESRCH
nil nil
else else
return if session && agent_dispatch?(session)
begin begin
Process.kill("KILL", -pid.to_i) Process.kill("KILL", -pid.to_i)
rescue Errno::ESRCH rescue Errno::ESRCH
@@ -1,13 +1,4 @@
module Teams module Teams
class StaffAssignmentError < StandardError
attr_reader :message
def initialize(message)
@message = message
super(message)
end
end
class StaffAssignment class StaffAssignment
def self.call(team:, user:, staff_kind: "transmission", membership: nil) def self.call(team:, user:, staff_kind: "transmission", membership: nil)
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
@@ -33,31 +24,4 @@ module Teams
ut ut
end end
end end
class StaffEmailValidator
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
new(team: team, email: email, except_user: except_user).assert_available!
end
def initialize(team:, email:, except_user: nil)
@team = team
@email = email.to_s.downcase.strip
@except_user = except_user
end
def assert_available!
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
if user_scope.exists?
raise StaffAssignmentError,
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
end
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
return unless inv.exists?
raise StaffAssignmentError,
"Esiste già un invito in sospeso per #{@email}."
end
end
end end
@@ -0,0 +1,10 @@
module Teams
class StaffAssignmentError < StandardError
attr_reader :message
def initialize(message)
@message = message
super(message)
end
end
end
@@ -0,0 +1,28 @@
module Teams
class StaffEmailValidator
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
new(team: team, email: email, except_user: except_user).assert_available!
end
def initialize(team:, email:, except_user: nil)
@team = team
@email = email.to_s.downcase.strip
@except_user = except_user
end
def assert_available!
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
if user_scope.exists?
raise StaffAssignmentError,
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
end
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
return unless inv.exists?
raise StaffAssignmentError,
"Esiste già un invito in sospeso per #{@email}."
end
end
end
@@ -32,7 +32,8 @@ module Youtube
end end
def team_channel_available? def team_channel_available?
@ent.youtube_enabled? && @ent.premium_full? && @mode == "team" && @team.club.youtube_credential.present? @ent.youtube_enabled? && @ent.premium_full? && @mode == "team" &&
@team.club.youtube_credential&.usable?
end end
def effective_channel def effective_channel
@@ -63,7 +63,6 @@ module Youtube
return return
end end
Mediamtx::Client.for_session(session).set_always_available(session, enabled: false)
session.go_live! if session.may_go_live? session.go_live! if session.may_go_live?
session.reconnect! if session.reconnecting? && session.may_reconnect? session.reconnect! if session.reconnecting? && session.may_reconnect?
+3 -3
View File
@@ -15,7 +15,7 @@ module Youtube
when "matchlivetv_light" when "matchlivetv_light"
PlatformCredential.configured? PlatformCredential.configured?
when "team" when "team"
@team.club.youtube_credential.present? || PlatformCredential.configured? @team.club.youtube_credential&.usable? || PlatformCredential.configured?
else else
false false
end end
@@ -31,7 +31,7 @@ module Youtube
if @mode == "matchlivetv_light" if @mode == "matchlivetv_light"
PlatformCredential.configured? PlatformCredential.configured?
elsif @mode == "team" elsif @mode == "team"
@team.club.youtube_credential.blank? && PlatformCredential.configured? !@team.club.youtube_credential&.usable? && PlatformCredential.configured?
else else
false false
end end
@@ -46,7 +46,7 @@ module Youtube
end end
def needs_team_oauth? def needs_team_oauth?
@mode == "team" && @ent.premium_full? && @team.club.youtube_credential.blank? && !PlatformCredential.configured? @mode == "team" && @ent.premium_full? && !@team.club.youtube_credential&.usable? && !PlatformCredential.configured?
end end
end end
end end
@@ -18,6 +18,52 @@
club: @filter_club, club: @filter_club,
subscription: @filter_club.subscription, subscription: @filter_club.subscription,
return_to: admin_billing_path(club_id: @filter_club.id) %> return_to: admin_billing_path(club_id: @filter_club.id) %>
<%= render "admin/clubs/quote_form",
club: @filter_club,
quote: @filter_club.active_billing_quote,
return_to: admin_billing_path(club_id: @filter_club.id) %>
<% end %>
<h2 style="margin-top:8px;font-size:1.15rem"><%= t("admin.billing.index.transfers_title") %></h2>
<p style="color:#666;margin-bottom:16px"><%= t("admin.billing.index.transfers_description") %></p>
<% if @pending_transfers.any? %>
<div class="billing-pending-list" style="margin-bottom:32px">
<% @pending_transfers.each do |order| %>
<article id="transfer-<%= order.id %>" class="billing-pending-card">
<header class="billing-pending-card__head">
<div>
<strong><%= order.club.name %></strong>
· <%= order.plan.name %>
· <%= t("admin.billing.index.kind_#{order.quoted? ? 'quote' : 'list'}") %>
· <strong><%= order.formatted_amount %></strong>
· <%= order.created_at.to_date.strftime("%d/%m/%Y") %>
</div>
</header>
<div class="billing-pending-card__body">
<p style="margin:0 0 12px">
<%= t("admin.billing.index.transfers_table.causal") %>:
<strong><%= order.payment_causal %></strong>
</p>
<%= form_with url: admin_billing_transfer_confirm_path(order),
method: :post, multipart: true, local: true, class: "billing-upload-form" do %>
<label class="billing-upload-form__label">
<%= t("admin.billing.index.pdf_optional") %>
<%= file_field_tag :pdf, accept: "application/pdf" %>
</label>
<%= submit_tag t("admin.billing.index.confirm_button"), class: "admin-btn admin-btn--primary" %>
<% end %>
<%= button_to t("admin.billing.index.cancel_button"),
admin_billing_transfer_cancel_path(order),
method: :post,
class: "admin-btn admin-btn--secondary",
form: { style: "margin-top:8px", data: { turbo_confirm: t("admin.billing.index.cancel_confirm") } } %>
</div>
</article>
<% end %>
</div>
<% else %>
<p class="muted" style="margin-bottom:28px"><%= t("admin.billing.index.transfers_none") %></p>
<% end %> <% end %>
<% if @pending_payments.any? %> <% if @pending_payments.any? %>
@@ -0,0 +1,71 @@
<%# locals: (club:, quote:, return_to: nil) %>
<section class="admin-comped-card" style="margin-bottom:24px;padding:16px;border:1px solid #2e5a3c;border-radius:8px;background:#142018">
<h2 style="margin:0 0 8px;font-size:1.1rem"><%= t("admin.quote.title") %></h2>
<p style="color:#bbb;font-size:0.9rem;margin:0 0 14px">
<%= t("admin.quote.description") %>
</p>
<% if quote %>
<p style="margin:0 0 12px">
<%= raw t("admin.quote.active_html",
plan: quote.plan.name,
interval: t("admin.quote.interval_#{quote.billing_interval}"),
amount: quote.formatted_amount) %>
<% if quote.note.present? %>
<br><span style="color:#888;font-size:0.85rem"><%= t("admin.quote.note_label") %> <%= quote.note %></span>
<% end %>
</p>
<% else %>
<p style="margin:0 0 12px;color:#888"><%= t("admin.quote.none") %></p>
<% end %>
<%= form_with url: set_quote_admin_club_path(club), method: :post, local: true do %>
<%= hidden_field_tag :return_to, return_to if return_to.present? %>
<div style="display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end">
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
<%= t("admin.quote.plan_label") %>
<%= select_tag :plan_slug,
options_for_select(
[["Premium Light", "premium_light"], ["Premium Full", "premium_full"]],
quote&.plan_slug
),
required: true,
class: "admin-input" %>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
<%= t("admin.quote.interval_label") %>
<%= select_tag :interval,
options_for_select(
[[t("admin.quote.interval_yearly"), "yearly"], [t("admin.quote.interval_monthly"), "monthly"]],
quote&.billing_interval || "yearly"
),
required: true,
class: "admin-input" %>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem">
<%= t("admin.quote.amount_label") %>
<%= text_field_tag :amount_euros,
quote ? format("%.2f", quote.amount_cents / 100.0) : nil,
placeholder: t("admin.quote.amount_placeholder"),
required: true,
class: "admin-input",
inputmode: "decimal" %>
</label>
<label style="display:flex;flex-direction:column;gap:4px;font-size:0.85rem;flex:1;min-width:200px">
<%= t("admin.quote.commercial_note_label") %>
<%= text_field_tag :note, quote&.note, class: "admin-input", style: "width:100%" %>
</label>
<%= submit_tag t("admin.quote.save_button"), class: "admin-btn admin-btn--primary" %>
</div>
<% end %>
<% if quote %>
<div style="margin-top:12px">
<%= button_to t("admin.quote.revoke_button"),
revoke_quote_admin_club_path(club, return_to: return_to),
method: :delete,
class: "admin-btn admin-btn--secondary",
form: { data: { turbo_confirm: t("admin.quote.revoke_confirm", club: club.name) } } %>
</div>
<% end %>
</section>
@@ -11,6 +11,7 @@
<th><%= t("admin.clubs.index.table.teams") %></th> <th><%= t("admin.clubs.index.table.teams") %></th>
<th><%= t("admin.clubs.index.table.comped") %></th> <th><%= t("admin.clubs.index.table.comped") %></th>
<th><%= t("admin.clubs.index.table.stripe") %></th> <th><%= t("admin.clubs.index.table.stripe") %></th>
<th><%= t("admin.clubs.index.table.quote") %></th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@@ -30,6 +31,14 @@
<% end %> <% end %>
</td> </td>
<td><%= sub&.stripe_subscription_id.present? ? t("admin.common.yes") : t("admin.common.dash") %></td> <td><%= sub&.stripe_subscription_id.present? ? t("admin.common.yes") : t("admin.common.dash") %></td>
<td>
<% quote = club.billing_quote %>
<% if quote %>
<%= quote.plan.name %> · <%= quote.formatted_amount %>
<% else %>
<%= t("admin.common.dash") %>
<% end %>
</td>
<td> <td>
<%= link_to t("admin.clubs.index.manage"), admin_club_path(club) %> <%= link_to t("admin.clubs.index.manage"), admin_club_path(club) %>
· <%= link_to t("admin.clubs.index.invoices"), admin_billing_path(club_id: club.id) %> · <%= link_to t("admin.clubs.index.invoices"), admin_billing_path(club_id: club.id) %>
@@ -9,6 +9,7 @@
</p> </p>
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> <%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
<%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
<% cred = @club.youtube_credential %> <% cred = @club.youtube_credential %>
<p style="margin-top:16px"> <p style="margin-top:16px">
@@ -0,0 +1,25 @@
<p><%= t("mailers.bank_transfer.instructions.hello") %></p>
<p><%= raw t("mailers.bank_transfer.instructions.intro_html",
club: @club.billing_legal_name.presence || @club.name,
plan: @order.plan.name,
price: @order.price_label) %></p>
<p><%= t("mailers.bank_transfer.instructions.not_active_yet") %></p>
<p>
<strong><%= t("mailers.bank_transfer.instructions.iban_label") %></strong> <%= @iban %><br>
<strong><%= t("mailers.bank_transfer.instructions.holder_label") %></strong> <%= @holder %><br>
<% if @bank_name.present? %>
<strong><%= t("mailers.bank_transfer.instructions.bank_label") %></strong> <%= @bank_name %><br>
<% end %>
<% if @bic.present? %>
<strong><%= t("mailers.bank_transfer.instructions.bic_label") %></strong> <%= @bic %><br>
<% end %>
<strong><%= t("mailers.bank_transfer.instructions.amount_label") %></strong> <%= @order.formatted_amount %><br>
<strong><%= t("mailers.bank_transfer.instructions.causal_label") %></strong> <%= @order.payment_causal %>
</p>
<p><%= raw t("mailers.bank_transfer.instructions.proof_html", email: @proof_email) %></p>
<p><%= t("mailers.bank_transfer.thanks") %><br><%= t("mailers.bank_transfer.signoff") %></p>
@@ -0,0 +1,20 @@
<%= t("mailers.bank_transfer.instructions.hello") %>
<%= t("mailers.bank_transfer.instructions.intro_text",
club: @club.billing_legal_name.presence || @club.name,
plan: @order.plan.name,
price: @order.price_label) %>
<%= t("mailers.bank_transfer.instructions.not_active_yet") %>
<%= t("mailers.bank_transfer.instructions.iban_label") %> <%= @iban %>
<%= t("mailers.bank_transfer.instructions.holder_label") %> <%= @holder %>
<% if @bank_name.present? %><%= t("mailers.bank_transfer.instructions.bank_label") %> <%= @bank_name %>
<% end %><% if @bic.present? %><%= t("mailers.bank_transfer.instructions.bic_label") %> <%= @bic %>
<% end %><%= t("mailers.bank_transfer.instructions.amount_label") %> <%= @order.formatted_amount %>
<%= t("mailers.bank_transfer.instructions.causal_label") %> <%= @order.payment_causal %>
<%= t("mailers.bank_transfer.instructions.proof_text", email: @proof_email) %>
<%= t("mailers.bank_transfer.thanks") %>
<%= t("mailers.bank_transfer.signoff") %>
@@ -0,0 +1,10 @@
<p><%= t("mailers.bank_transfer.plan_activated.hello") %></p>
<p><%= raw t("mailers.bank_transfer.plan_activated.body_html",
plan: @order.plan.name,
club: @club.billing_legal_name.presence || @club.name,
until_date: @subscription&.current_period_end ? l(@subscription.current_period_end.to_date, format: :long) : t("mailers.bank_transfer.plan_activated.period_fallback")) %></p>
<p><%= t("mailers.bank_transfer.plan_activated.invoice_later") %></p>
<p><%= t("mailers.bank_transfer.thanks") %><br><%= t("mailers.bank_transfer.signoff") %></p>
@@ -0,0 +1,11 @@
<%= t("mailers.bank_transfer.plan_activated.hello") %>
<%= t("mailers.bank_transfer.plan_activated.body_text",
plan: @order.plan.name,
club: @club.billing_legal_name.presence || @club.name,
until_date: @subscription&.current_period_end ? l(@subscription.current_period_end.to_date, format: :long) : t("mailers.bank_transfer.plan_activated.period_fallback")) %>
<%= t("mailers.bank_transfer.plan_activated.invoice_later") %>
<%= t("mailers.bank_transfer.thanks") %>
<%= t("mailers.bank_transfer.signoff") %>
@@ -0,0 +1,12 @@
<p><%= t("mailers.invoice_activated.hello") %></p>
<p><%= raw t("mailers.invoice_activated.body_html",
plan: @invoice.club.subscription&.plan&.name || @invoice.billing_payment&.plan_slug.to_s.humanize,
club: @club.billing_legal_name.presence || @club.name,
until_date: @invoice.club.subscription&.current_period_end ? l(@invoice.club.subscription.current_period_end.to_date, format: :long) : t("mailers.invoice_activated.period_fallback"),
number: @invoice.display_number,
amount: @invoice.formatted_amount) %></p>
<p><%= raw t("mailers.invoice.also_html") %></p>
<p><%= t("mailers.invoice.thanks") %><br><%= t("mailers.invoice.signoff") %></p>
@@ -0,0 +1,13 @@
<%= t("mailers.invoice_activated.hello") %>
<%= t("mailers.invoice_activated.body_text",
plan: @invoice.club.subscription&.plan&.name || @invoice.billing_payment&.plan_slug.to_s.humanize,
club: @club.billing_legal_name.presence || @club.name,
until_date: @invoice.club.subscription&.current_period_end ? l(@invoice.club.subscription.current_period_end.to_date, format: :long) : t("mailers.invoice_activated.period_fallback"),
number: @invoice.display_number,
amount: @invoice.formatted_amount) %>
<%= t("mailers.invoice.also_text") %>
<%= t("mailers.invoice.thanks") %>
<%= t("mailers.invoice.signoff") %>
+1 -1
View File
@@ -7,7 +7,7 @@
</style> </style>
</head> </head>
<body> <body style="margin:0;padding:24px;background:#ffffff;color:#1a1a1a;">
<%= yield %> <%= yield %>
</body> </body>
</html> </html>
+1 -1
View File
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= yield :head %> <%= yield :head %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=50"> <link rel="stylesheet" href="/marketing.css?v=64">
</head> </head>
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
<%= render "shared/cookie_banner" %> <%= render "shared/cookie_banner" %>
@@ -6,7 +6,7 @@
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title> <title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
<link rel="stylesheet" href="/marketing.css?v=50"> <link rel="stylesheet" href="/marketing.css?v=64">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -17,11 +17,19 @@
</p> </p>
</div> </div>
<% else %> <% else %>
<% if @quote %>
<%= render "shared/quoted_price_banner", quote: @quote %>
<% elsif MatchLiveTv.stripe_enabled? %>
<%= render "shared/stripe_secure_payment" %> <%= render "shared/stripe_secure_payment" %>
<% if MatchLiveTv.stripe_enabled? %> <% end %>
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
<%= render "shared/plan_change_info", subscription: @subscription %> <%= render "shared/plan_change_info", subscription: @subscription %>
<% end %> <% end %>
<%= render "shared/plan_cards", show_stripe_portal: false %> <%= render "shared/plan_cards", show_stripe_portal: false %>
<% if @subscription&.bank_transfer? %>
<p class="muted" style="margin-top:16px"><%= raw t("billing.bank_transfer.renewal_html") %></p>
<% end %>
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %> <%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
<% end %> <% end %>
+10 -6
View File
@@ -54,6 +54,7 @@
</div> </div>
<p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p> <p id="offline-msg" class="live-status-msg" hidden><%= t("live.show.offline_msg") %></p>
<p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p> <p id="awaiting-msg" class="live-status-msg" hidden><%= t("live.show.awaiting_msg") %></p>
<p id="reconnecting-msg" class="live-status-msg" hidden><%= t("live.show.js_reconnecting_awaiting") %></p>
<p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p> <p id="paused-msg" class="live-status-msg" hidden><%= t("live.show.paused_msg") %></p>
<script> <script>
@@ -63,25 +64,26 @@
const offlineMsg = document.getElementById("offline-msg"); const offlineMsg = document.getElementById("offline-msg");
const pausedMsg = document.getElementById("paused-msg"); const pausedMsg = document.getElementById("paused-msg");
const awaitingMsg = document.getElementById("awaiting-msg"); const awaitingMsg = document.getElementById("awaiting-msg");
const reconnectingMsg = document.getElementById("reconnecting-msg");
const playHint = document.getElementById("play-hint"); const playHint = document.getElementById("play-hint");
const streamBadge = document.getElementById("live-stream-badge"); const streamBadge = document.getElementById("live-stream-badge");
function syncStreamBadge(data) { function syncStreamBadge(data) {
if (!streamBadge) return; if (!streamBadge) return;
if (data.stream_closed) { if (data.stream_closed) {
streamBadge.textContent = "<%= j t("live.overlays.badge_ended") %>"; streamBadge.textContent = <%= raw t("live.overlays.badge_ended").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-ended";
return; return;
} }
const paused = !!(data.paused || data.status === "paused"); const paused = !!(data.paused || data.status === "paused");
if (paused) { if (paused) {
streamBadge.textContent = "<%= j t("live.index.badge_paused") %>"; streamBadge.textContent = <%= raw t("live.index.badge_paused").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} else if (data.on_air) { } else if (data.on_air) {
streamBadge.textContent = "<%= j t("live.index.badge_live") %>"; streamBadge.textContent = <%= raw t("live.index.badge_live").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-live";
} else { } else {
streamBadge.textContent = "<%= j t("live.overlays.badge_waiting") %>"; streamBadge.textContent = <%= raw t("live.overlays.badge_waiting").to_json %>;
streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait"; streamBadge.className = "live-ovl-badge live-ovl-badge--right badge-wait";
} }
} }
@@ -282,9 +284,10 @@
if (onAir || sessionLive) { if (onAir || sessionLive) {
offlineMsg.hidden = true; offlineMsg.hidden = true;
if (!publisherOnline && showingCover && !paused) { if (!publisherOnline && showingCover && !paused) {
awaitingMsg.hidden = false; awaitingMsg.hidden = true;
awaitingMsg.textContent = "<%= j t("live.show.js_reconnecting_awaiting") %>"; if (reconnectingMsg) reconnectingMsg.hidden = false;
} else { } else {
if (reconnectingMsg) reconnectingMsg.hidden = true;
awaitingMsg.hidden = !awaitingSignal; awaitingMsg.hidden = !awaitingSignal;
} }
pausedMsg.hidden = !paused; pausedMsg.hidden = !paused;
@@ -299,6 +302,7 @@
} else { } else {
offlineMsg.hidden = false; offlineMsg.hidden = false;
awaitingMsg.hidden = true; awaitingMsg.hidden = true;
if (reconnectingMsg) reconnectingMsg.hidden = true;
pausedMsg.hidden = true; pausedMsg.hidden = true;
} }
+263 -43
View File
@@ -2,63 +2,283 @@
<% content_for :meta_description, t("pages.features.meta_description") %> <% content_for :meta_description, t("pages.features.meta_description") %>
<% content_for :canonical_url, seo_absolute_url(public_features_path) %> <% content_for :canonical_url, seo_absolute_url(public_features_path) %>
<div class="wrap"> <article class="features-page">
<h1 style="margin-top:24px"><%= t("pages.features.title") %></h1> <header class="features-hero">
<p style="color:#aaa;max-width:640px"><%= t("pages.features.lead") %></p> <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"> <ol class="features-flow" aria-label="<%= t("pages.features.flow_aria") %>">
<h2><%= t("pages.features.staff_section_title") %></h2> <li class="features-flow__item">
<div class="features-grid"> <span class="features-flow__icon" aria-hidden="true"><i class="fa-solid fa-mobile-screen"></i></span>
<div class="feature-card"> <span class="features-flow__label"><%= t("pages.features.flow_phone") %></span>
<h3><%= t("pages.features.staff_invite_title") %></h3> </li>
<p><%= t("pages.features.staff_invite_body") %></p> <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>
<div class="feature-card">
<h3><%= t("pages.features.staff_access_title") %></h3>
<p><%= t("pages.features.staff_access_body") %></p>
</div> </div>
<div class="feature-card">
<h3><%= t("pages.features.staff_plans_title") %></h3>
<p><%= t("pages.features.staff_plans_body") %></p>
</div> </div>
</div> </div>
</section> </section>
<section class="section"> <section class="section wrap features-replay" aria-labelledby="features-replay-title">
<h2><%= t("pages.features.live_section_title") %></h2> <div class="features-split features-split--reverse">
<div class="features-grid"> <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"> <div class="feature-card">
<h3><%= t("pages.features.live_phone_title") %></h3> <span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
<p><%= t("pages.features.live_phone_body") %></p> <h3><%= t("pages.features.more_stable_title") %></h3>
<p><%= t("pages.features.more_stable_body") %></p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<h3><%= t("pages.features.live_score_title") %></h3> <span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-share-nodes"></i></span>
<p><%= t("pages.features.live_score_body") %></p> <h3><%= t("pages.features.more_link_title") %></h3>
<p><%= t("pages.features.more_link_body") %></p>
</div> </div>
<div class="feature-card"> <div class="feature-card">
<h3><%= t("pages.features.live_link_title") %></h3> <span class="feature-card__icon" aria-hidden="true"><i class="fa-brands fa-youtube"></i></span>
<p><%= t("pages.features.live_link_body") %></p> <h3><%= t("pages.features.more_yt_mltv_title") %></h3>
</div> <p><%= t("pages.features.more_yt_mltv_body") %></p>
<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>
</div> </div>
</div> </div>
</section> </section>
<p style="text-align:center;margin:40px 0"> <section class="section wrap features-cta" aria-labelledby="features-cta-title">
<%= link_to t("pages.features.cta_signup"), public_signup_path, class: "btn btn-primary" %> <div class="features-cta__box">
</p> <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>
</div>
</section>
</article>
+6 -1
View File
@@ -10,7 +10,8 @@
"url" => seo_absolute_url(root_path), "url" => seo_absolute_url(root_path),
"applicationCategory" => "SportsApplication", "applicationCategory" => "SportsApplication",
"operatingSystem" => "Web, Android, iOS", "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) %> }.to_json) %>
</script> </script>
<% end %> <% end %>
@@ -26,6 +27,10 @@
<%= link_to t("home.cta_signup"), public_signup_path, class: "btn btn-primary" %> <%= 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" %> <%= link_to t("home.cta_live"), public_live_index_path, class: "btn btn-secondary" %>
</div> </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') %>"> <ul class="hero-features" aria-label="<%= t('home.features_aria') %>">
<li class="hero-feature-item"> <li class="hero-feature-item">
<span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span> <span class="hero-feature-icon" aria-hidden="true"><i class="fa-solid fa-tower-broadcast"></i></span>
@@ -22,19 +22,60 @@
<%= render "shared/plan_cards" %> <%= render "shared/plan_cards" %>
<div class="table-scroll"> <div class="table-scroll compare-table-wrap">
<table class="compare-table"> <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> <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> </thead>
<tbody> <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_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_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_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_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_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> </tbody>
</table> </table>
</div> </div>
@@ -6,7 +6,7 @@
<% recordings = local_assigns[:recordings] %> <% recordings = local_assigns[:recordings] %>
<% entitlements = local_assigns[:entitlements] %> <% entitlements = local_assigns[:entitlements] %>
<section class="team-streaming-staff"> <section class="team-streaming-staff" id="responsabili-trasmissione">
<% if can_manage %> <% if can_manage %>
<% if owner_membership&.staff_kind.blank? %> <% if owner_membership&.staff_kind.blank? %>
<div class="card team-streaming-staff__self"> <div class="card team-streaming-staff__self">
@@ -51,12 +51,34 @@
<% else %> <% else %>
<p class="muted"> <p class="muted">
<%= t("team.streaming_staff.no_staff") %> <%= t("team.streaming_staff.no_staff") %>
<% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), public_team_invite_path(team) %>.<% end %> <% if can_manage %><%= link_to t("team.streaming_staff.invite_someone"), "#invita-trasmissione" %>.<% end %>
</p> </p>
<% end %> <% end %>
</div> </div>
<% if can_manage %> <% if can_manage %>
<div class="card team-invite-form" id="invita-trasmissione">
<h2><%= t("team.streaming_staff.invite_form_heading") %></h2>
<p class="muted team-invite-form__hint"><%= t("team.streaming_staff.invite_form_hint") %></p>
<%= form_with url: public_team_invite_path(team), method: :post, class: "team-invite-form__fields" do %>
<%= hidden_field_tag :staff_kind, "transmission" %>
<%= label_tag :email, t("team.invite.email_label") %>
<%= email_field_tag :email, params[:email], required: true, autocomplete: "email", placeholder: "nome@email.it" %>
<%= submit_tag t("team.invite.submit"), class: "btn btn-primary" %>
<% end %>
<% if flash[:invite_url].present? %>
<div class="team-invite-link" id="invito-generato">
<p class="team-invite-link__title"><%= t("team.invite.share_hint") %></p>
<code class="team-invite-link__url" id="team-invite-url"><%= flash[:invite_url] %></code>
<button type="button" class="btn btn-secondary team-invite-link__copy" data-copy-target="team-invite-url">
<%= t("regia.copy_link") %>
</button>
<p class="muted team-invite-link__note"><%= t("team.streaming_staff.invite_once_note") %></p>
</div>
<% end %>
</div>
<h2><%= t("team.streaming_staff.pending_invitations_heading") %></h2> <h2><%= t("team.streaming_staff.pending_invitations_heading") %></h2>
<div class="card"> <div class="card">
<% if pending_invitations.any? %> <% if pending_invitations.any? %>
@@ -114,3 +136,18 @@
</div> </div>
<% end %> <% end %>
</section> </section>
<script>
document.querySelectorAll("[data-copy-target]").forEach(function (btn) {
btn.addEventListener("click", async function () {
var el = document.getElementById(btn.getAttribute("data-copy-target"));
if (!el) return;
try {
await navigator.clipboard.writeText(el.textContent.trim());
btn.textContent = <%= raw t("team.streaming_staff.copied").to_json %>;
} catch (e) {
window.getSelection().selectAllChildren(el);
}
});
});
</script>
@@ -56,7 +56,7 @@
<% if @can_manage %> <% if @can_manage %>
<%= link_to t("club.back_to_club"), public_club_path(@club), class: "btn btn-secondary" %> <%= link_to t("club.back_to_club"), public_club_path(@club), class: "btn btn-secondary" %>
<%= link_to t("team.details.public_page"), public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %> <%= link_to t("team.details.public_page"), public_team_page_path(@team.slug), class: "btn btn-secondary", target: "_blank", rel: "noopener" %>
<%= link_to t("team.details.streaming_managers"), public_team_invite_path(@team), class: "btn btn-secondary" %> <%= link_to t("team.details.streaming_managers"), "#responsabili-trasmissione", class: "btn btn-secondary" %>
<% end %> <% end %>
<% if current_user.can_stream_for?(@team) %> <% if current_user.can_stream_for?(@team) %>
<%= link_to t("team.details.schedule_matches"), public_team_matches_path(@team), class: "btn btn-primary" %> <%= link_to t("team.details.schedule_matches"), public_team_matches_path(@team), class: "btn btn-primary" %>
+3 -33
View File
@@ -1,38 +1,8 @@
<%# Pagina legacy: l'invito è sulla pagina dettagli squadra. %>
<% content_for :title, t("team.invite.title", name: @team.name) %> <% content_for :title, t("team.invite.title", name: @team.name) %>
<% content_for :robots, "noindex, nofollow" %> <% content_for :robots, "noindex, nofollow" %>
<div class="wrap" style="padding-top:20px"> <div class="wrap" style="padding-top:20px">
<h1><%= t("team.invite.heading") %></h1> <p class="muted"><%= t("team.streaming_staff.invite_form_hint") %></p>
<p> <p><%= link_to t("team.back_to_details"), public_team_details_path(@team, anchor: "invita-trasmissione"), class: "btn btn-primary" %></p>
<%= t("team.invite.assigned_label") %> <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
<%= t("team.invite.per_team_note") %>
</p>
<p class="muted" style="max-width:640px">
<%= raw t("team.invite.info") %>
</p>
<% owner_membership = current_user.user_teams.find_by(team: @team) %>
<% if owner_membership&.staff_kind.blank? %>
<div class="card" style="max-width:520px;margin-bottom:16px">
<p style="margin:0 0 12px;color:#aaa;font-size:0.92rem">
<%= raw t("team.invite.self_assign_hint_html", email: current_user.email) %>
</p>
<%= button_to t("team.invite.self_assign_submit"), public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
</div>
<% end %>
<div class="card" style="max-width:520px">
<h2 style="margin-top:0;font-size:1.05rem"><%= t("team.invite.invite_other_heading") %></h2>
<%= form_with url: public_team_invite_path(@team), method: :post do %>
<%= hidden_field_tag :staff_kind, "transmission" %>
<%= label_tag :email, t("team.invite.email_label") %>
<%= email_field_tag :email, params[:email], required: true %>
<%= submit_tag t("team.invite.submit"), class: "btn btn-primary" %>
<% end %>
<% if defined?(@invite_url) && @invite_url.present? %>
<p style="margin-top:16px"><%= t("team.invite.share_hint") %></p>
<code style="word-break:break-all;display:block;background:#0a0a0e;padding:12px;border-radius:8px"><%= @invite_url %></code>
<% end %>
</div>
<p><%= link_to t("team.back_to_details"), public_team_details_path(@team) %></p>
</div> </div>
@@ -33,4 +33,8 @@
<% end %> <% end %>
<% end %> <% end %>
</p> </p>
<% elsif subscription&.bank_transfer? %>
<p style="color:#888;font-size:0.9rem;margin-top:0">
<%= raw t("billing.subscription_status.bank_transfer_until_html", date: l_local(subscription.current_period_end.to_date)) %>
</p>
<% end %> <% end %>
@@ -2,6 +2,7 @@
<div class="wrap"> <div class="wrap">
<div> <div>
<strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %> <strong style="color:#fff">Match Live TV</strong><%= t("footer.tagline") %>
<%= render "shared/store_badges", variant: "footer" %>
</div> </div>
<div> <div>
<%= link_to t("common.support"), public_support_path %> · <%= link_to t("common.support"), public_support_path %> ·
@@ -0,0 +1,15 @@
<%# locals: (order:) %>
<% return if order.blank? %>
<div class="card" style="margin-top:16px;border-color:#3d3520;background:#1a1810">
<p style="margin:0 0 8px;color:#ddd">
<strong><%= t("billing.bank_transfer.pending_title") %></strong>
<%= raw t("billing.bank_transfer.pending_body_html",
plan: order.plan.name,
price: order.price_label,
causal: order.payment_causal) %>
</p>
<p style="margin:0;color:#aaa;font-size:0.9rem">
<%= raw t("billing.bank_transfer.pending_proof_html",
email_link: mail_to(MatchLiveTv.bank_transfer_proof_email, MatchLiveTv.bank_transfer_proof_email)) %>
</p>
</div>
+62 -17
View File
@@ -13,25 +13,43 @@
target_plan: plan, target_plan: plan,
stripe_subscription_active: stripe_subscription_active?(subscription), stripe_subscription_active: stripe_subscription_active?(subscription),
current_interval: current_interval, current_interval: current_interval,
subscription: subscription subscription: subscription,
club: club
) : nil %> ) : 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 %> <%= render "shared/plan_title", plan: plan %>
<% if plan.slug == "free" %> <% if plan.slug == "free" %>
<div class="plan-price">€0</div> <div class="plan-price">€0</div>
<% elsif plan.slug == "premium_light" %> <% elsif billing_mode && club&.active_billing_quote&.plan_slug == plan.slug %>
<% light_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %> <% quote = club.active_billing_quote %>
<% light_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %> <div class="plan-price-block">
<div class="plan-price" style="color:#e53935"><%= light_yearly %></div> <div class="plan-price<%= " plan-price--featured" if featured %>"><%= quote.formatted_amount %></div>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: light_monthly) %></p> <span class="plan-price-badge"><%= t("billing.bank_transfer.quoted_badge") %></span>
<p class="plan-price-equiv"><%= t("billing.bank_transfer.quoted_interval.#{quote.billing_interval}") %></p>
</div>
<% else %> <% else %>
<% full_yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %> <% yearly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "yearly") %>
<% full_monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %> <% monthly = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: "monthly") %>
<div class="plan-price"><%= full_yearly %></div> <% list = Billing::Stripe::PriceCatalog.list_label(plan_slug: plan.slug, interval: "yearly") %>
<p class="plan-price-alt"><%= raw t("pages.plans.price_alt_html", price: full_monthly) %></p> <% 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 %> <% end %>
<ul> <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.matches_html", count: plan.concurrent_streams_limit || t("pages.plans.matches_unlimited")) %></li>
<li><%= raw t( <li><%= raw t(
"pages.plans.replay_html", "pages.plans.replay_html",
@@ -48,14 +66,19 @@
end end
) %></li> ) %></li>
</ul> </ul>
<% if plan.slug == "premium_full" %>
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
<% end %>
<% if billing_mode %> <% if billing_mode %>
<% action_kind = action[:kind] %> <% action_kind = action[:kind] %>
<% quote = club&.active_billing_quote %>
<% pending_transfer = club&.pending_transfer_order %>
<% if action_kind == :current %> <% if action_kind == :current %>
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span> <span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
<% elsif action_kind == :none %> <% elsif action_kind == :none %>
<% elsif action_kind.in?(%i[contact disabled]) %> <% elsif action_kind.in?(%i[contact disabled quoted_other]) %>
<p class="plan-action-hint"><%= action[:label] %></p> <p class="plan-action-hint"><%= action[:label] %></p>
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %> <% elsif action_kind.in?(%i[checkout_options change_options interval_switch quoted bank_only]) %>
<% if billing_profile_blocks_premium?(club) %> <% if billing_profile_blocks_premium?(club) %>
<p class="plan-action-hint" style="color:#e53935;margin-bottom:10px"> <p class="plan-action-hint" style="color:#e53935;margin-bottom:10px">
<%= billing_profile_incomplete_message(club) %> <%= billing_profile_incomplete_message(club) %>
@@ -64,6 +87,7 @@
public_club_billing_profile_path(club, plan: plan.slug), public_club_billing_profile_path(club, plan: plan.slug),
class: "btn btn-primary" %> class: "btn btn-primary" %>
<% else %> <% else %>
<% unless action_kind.in?(%i[quoted bank_only]) %>
<div class="plan-interval-actions"> <div class="plan-interval-actions">
<% plan_intervals_for_display(action[:intervals]).each do |interval| %> <% plan_intervals_for_display(action[:intervals]).each do |interval| %>
<% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %> <% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %>
@@ -73,13 +97,35 @@
<% end %> <% end %>
</div> </div>
<% end %> <% end %>
<% transfer_intervals = action_kind == :quoted ? action[:intervals] : (action[:intervals].presence || bank_transfer_intervals_for(plan)) %>
<% if MatchLiveTv.bank_transfer_configured? && transfer_intervals.any? %>
<% unless action_kind.in?(%i[quoted bank_only]) %>
<p class="plan-action-hint" style="margin:10px 0 6px"><%= t("billing.bank_transfer.or_label") %></p>
<% end %>
<div class="plan-interval-actions">
<% plan_intervals_for_display(transfer_intervals).each do |interval| %>
<% next unless show_bank_transfer_for?(club: club, plan: plan, interval: interval, quote: quote, pending_transfer: pending_transfer) %>
<%= button_to t("billing.bank_transfer.pay_button_with_price", price: bank_transfer_price_label(plan, interval, quote: quote)),
public_club_billing_bank_transfer_path(club, plan: plan.slug, interval: interval),
method: :post,
class: "btn btn-outline plan-interval-btn",
form: { data: { turbo_confirm: t("billing.bank_transfer.hint") } } %>
<% end %>
</div>
<p class="plan-action-hint" style="margin-top:8px"><%= t("billing.bank_transfer.hint") %></p>
<% end %>
<% end %>
<% end %> <% end %>
<% elsif plan.slug == "free" %> <% elsif plan.slug == "free" %>
<%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %> <%= link_to t("pages.plans.start_free"), public_signup_path, class: "btn btn-secondary" %>
<% else %> <% else %>
<div class="plan-interval-actions"> <div class="plan-interval-actions">
<% plan_intervals_for_display(Billing::Stripe::PriceCatalog.available_intervals(plan_slug: plan.slug)).each do |interval| %> <% %w[yearly monthly].each do |interval| %>
<%= link_to t("pages.plans.register_with_price", price: Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: 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), public_signup_path(plan: plan.slug, interval: interval),
class: plan_interval_button_class(interval) %> class: plan_interval_button_class(interval) %>
<% end %> <% end %>
@@ -88,4 +134,3 @@
</div> </div>
<% end %> <% end %>
</div> </div>
@@ -0,0 +1,11 @@
<%# locals: (quote:) %>
<% return if quote.blank? %>
<div class="card" style="margin-top:16px;border-color:#2e5a3c;background:#142018">
<p style="margin:0;color:#ddd">
<strong><%= t("billing.bank_transfer.quote_banner_title") %></strong>
<%= raw t("billing.bank_transfer.quote_banner_body_html", plan: quote.plan.name, price: quote.price_label) %>
</p>
<% if quote.note.present? %>
<p style="margin:8px 0 0;color:#aaa;font-size:0.9rem"><%= t("billing.bank_transfer.quote_note", note: quote.note) %></p>
<% end %>
</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>
@@ -1,5 +1,5 @@
<%# locals: (club:, subscription:) %> <%# locals: (club:, subscription:) %>
<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %> <% return unless subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription.stripe_subscription_id.present? %>
<section id="subscription-cancel" class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px"> <section id="subscription-cancel" class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px">
<% if subscription.cancel_at_period_end? %> <% if subscription.cancel_at_period_end? %>
@@ -0,0 +1,31 @@
<div style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 16px; line-height: 1.5; color: #1a1a1a; max-width: 560px;">
<p style="margin: 0 0 16px;"><%= t("mailers.transmission_invite.hello") %></p>
<p style="margin: 0 0 16px;">
<%= raw t(
"mailers.transmission_invite.body_html",
inviter: @invited_by.name,
team: @team.name,
club: @club.name
) %>
</p>
<p style="margin: 0 0 16px;">
<a href="<%= @invite_url %>" style="display: inline-block; background: #e53935; color: #ffffff; text-decoration: none; padding: 12px 18px; border-radius: 8px; font-weight: 600;">
<%= t("mailers.transmission_invite.cta") %>
</a>
</p>
<p style="margin: 0 0 16px; color: #444; font-size: 14px;">
<%= t("mailers.transmission_invite.link_fallback") %><br>
<a href="<%= @invite_url %>" style="color: #e53935; word-break: break-all;"><%= @invite_url %></a>
</p>
<p style="margin: 0 0 16px;"><%= t("mailers.transmission_invite.steps") %></p>
<p style="margin: 0 0 16px;"><%= t("mailers.transmission_invite.expiry", date: @expires_on) %></p>
<p style="margin: 0 0 24px; color: #666;"><%= t("mailers.transmission_invite.ignore") %></p>
<p style="margin: 0; color: #888; font-size: 12px;"><%= t("mailers.transmission_invite.footer", email: MatchLiveTv.privacy_controller_email) %></p>
</div>
@@ -0,0 +1,19 @@
<%= t("mailers.transmission_invite.hello") %>
<%= t(
"mailers.transmission_invite.body_text",
inviter: @invited_by.name,
team: @team.name,
club: @club.name
) %>
<%= t("mailers.transmission_invite.link_text") %>
<%= @invite_url %>
<%= t("mailers.transmission_invite.steps") %>
<%= t("mailers.transmission_invite.expiry", date: @expires_on) %>
<%= t("mailers.transmission_invite.ignore") %>
<%= t("mailers.transmission_invite.footer", email: MatchLiveTv.privacy_controller_email) %>
@@ -73,6 +73,30 @@ module MatchLiveTv
stripe_secret_key.present? stripe_secret_key.present?
end end
def bank_transfer_iban
ENV["BANK_TRANSFER_IBAN"].to_s.gsub(/\s+/, "").presence
end
def bank_transfer_account_holder
ENV["BANK_TRANSFER_ACCOUNT_HOLDER"].to_s.strip.presence
end
def bank_transfer_bank_name
ENV["BANK_TRANSFER_BANK_NAME"].to_s.strip.presence
end
def bank_transfer_bic
ENV["BANK_TRANSFER_BIC"].to_s.strip.presence
end
def bank_transfer_proof_email
ENV["BANK_TRANSFER_PROOF_EMAIL"].presence || support_email
end
def bank_transfer_configured?
bank_transfer_iban.present? && bank_transfer_account_holder.present?
end
def privacy_controller_name def privacy_controller_name
ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro") ENV.fetch("PRIVACY_CONTROLLER_NAME", "Emiliano Frascaro")
end end
@@ -180,5 +204,13 @@ module MatchLiveTv
def google_analytics_configured? def google_analytics_configured?
google_analytics_measurement_id.present? google_analytics_measurement_id.present?
end 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
end end
+36
View File
@@ -44,6 +44,10 @@ it:
invoice_issued: "Fattura %{number} emessa e inviata a %{email}." invoice_issued: "Fattura %{number} emessa e inviata a %{email}."
invoice_updated: "Fattura %{number} aggiornata." invoice_updated: "Fattura %{number} aggiornata."
invoice_uploaded: "Fattura caricata e inviata a %{email}." invoice_uploaded: "Fattura caricata e inviata a %{email}."
quote_saved: "Prezzo concordato salvato per %{club}: %{plan} a %{amount}."
quote_revoked: "Prezzo concordato revocato per %{club}."
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato.
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
common: common:
free_plan: Free free_plan: Free
@@ -193,6 +197,7 @@ it:
teams: Squadre teams: Squadre
comped: Omaggio comped: Omaggio
stripe: Stripe stripe: Stripe
quote: Concordato
manage: Gestisci manage: Gestisci
invoices: Fatture invoices: Fatture
show: show:
@@ -228,6 +233,22 @@ it:
reason_label: "Motivo (es. sponsor 2026)" reason_label: "Motivo (es. sponsor 2026)"
reason_placeholder: "Sponsor, promozione…" reason_placeholder: "Sponsor, promozione…"
grant_button: Concedi omaggio grant_button: Concedi omaggio
quote:
title: Prezzo concordato (bonifico)
description: "Importo commerciale per questa società: piano, cadenza e cifra in euro. In billing la società vede solo questo prezzo e può pagare solo con bonifico. L'omaggio (0 €) resta nella card sopra."
none: Nessun prezzo concordato. La società paga a listino (carta o bonifico).
active_html: "<strong>%{plan}</strong> · %{interval} · <strong>%{amount}</strong>"
note_label: "Nota:"
plan_label: Piano
interval_label: Cadenza
interval_yearly: Annuale
interval_monthly: Mensile
amount_label: "Importo (€)"
amount_placeholder: "es. 150 oppure 150,00"
commercial_note_label: Nota commerciale (opzionale)
save_button: Salva prezzo concordato
revoke_button: Revoca prezzo concordato
revoke_confirm: "Revocare il prezzo concordato per %{club}? Tornerà il listino (carta + bonifico)."
teams: teams:
show: show:
back: "← %{club}" back: "← %{club}"
@@ -281,6 +302,21 @@ it:
index: index:
title: Pagamenti da fatturare title: Pagamenti da fatturare
description: "Pagamenti Stripe pagati senza PDF fattura. Genera il PDF nei tuoi sistemi, poi caricalo qui: viene associato al pagamento e inviato via email al cliente." description: "Pagamenti Stripe pagati senza PDF fattura. Genera il PDF nei tuoi sistemi, poi caricalo qui: viene associato al pagamento e inviato via email al cliente."
transfers_title: Bonifici in attesa
transfers_description: "La società ha chiesto di pagare con bonifico: il piano non è ancora attivo. Conferma dopo l'accredito (puoi allegare subito il PDF fattura)."
transfers_none: Nessun bonifico in attesa.
transfers_table:
club: Società
plan: Piano
amount: Importo
causal: Causale
requested: Richiesto
confirm_button: Conferma pagamento e attiva
cancel_button: Annulla richiesta
cancel_confirm: Annullare questa richiesta di bonifico?
pdf_optional: PDF fattura (opzionale)
kind_quote: prezzo concordato
kind_list: listino
filter_label: "Filtra società:" filter_label: "Filtra società:"
filter_all: Tutte filter_all: Tutte
incomplete_profile_warning: Dati fatturazione incompleti incomplete_profile_warning: Dati fatturazione incompleti
+14 -5
View File
@@ -82,12 +82,12 @@ de:
first_team_name_label: Teamname first_team_name_label: Teamname
first_team_name_placeholder: "z. B. Under 15" first_team_name_placeholder: "z. B. Under 15"
plan_label: "Starttarif (für den gesamten Verein)" plan_label: "Starttarif (für den gesamten Verein)"
plan_free: "Free — 1 Übertragungsverantwortlicher pro Team, 1 Livestream" plan_free: "Free — 1 Streaming-Konto pro Team, 1 Livestream"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Premium-Abrechnung interval_label: Premium-Abrechnung
interval_yearly: "Jährlich — 40 €/Jahr (Light) oder 200 €/Jahr (Full)" interval_yearly: "Jährlich — 59 €/Jahr (Light) oder 199 €/Jahr (Full)"
interval_monthly: "Monatlich — 5 €/Monat (Light) oder 20 €/Monat (Full)" interval_monthly: "Monatlich — 7,90 €/Monat (Light) oder 24,90 €/Monat (Full)"
submit: Verein erstellen submit: Verein erstellen
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
@@ -170,8 +170,8 @@ de:
self_assign_submit: Das mache ich — Übertragungsverantwortlicher self_assign_submit: Das mache ich — Übertragungsverantwortlicher
invite_other_heading: Eine weitere E-Mail einladen invite_other_heading: Eine weitere E-Mail einladen
email_label: E-Mail des Übertragungsverantwortlichen email_label: E-Mail des Übertragungsverantwortlichen
submit: Zugangslink erstellen submit: Zugangslink per E-Mail senden
share_hint: "Teilen (7 Tage gültig):" share_hint: "Link auch per E-Mail gesendet (7 Tage gültig):"
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
team_label: "Team:" team_label: "Team:"
@@ -208,6 +208,10 @@ de:
revoke_confirm: "Zugriff von %{name} widerrufen?" revoke_confirm: "Zugriff von %{name} widerrufen?"
no_staff: Noch keine Übertragungsverantwortlichen. no_staff: Noch keine Übertragungsverantwortlichen.
invite_someone: Jemanden einladen invite_someone: Jemanden einladen
invite_form_heading: Account zum Übertragen einladen
invite_form_hint: "Das ist nicht der Kader (Spieler/Staff vor Ort). E-Mail eingeben: wir senden den Link automatisch; du kannst ihn auch kopieren und teilen."
invite_once_note: "Bei Bedarf den Link unten auch für einen zweiten Versand kopieren (WhatsApp, SMS)."
copied: Kopiert
pending_invitations_heading: Ausstehende Einladungen pending_invitations_heading: Ausstehende Einladungen
col_email_short: E-Mail col_email_short: E-Mail
col_expires: Läuft ab col_expires: Läuft ab
@@ -423,6 +427,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." 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." 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." 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: regia:
meta_title_fallback: "Regie — Match Live TV" meta_title_fallback: "Regie — Match Live TV"
title: "Regie — %{home} vs %{away}" title: "Regie — %{home} vs %{away}"
@@ -633,6 +640,8 @@ de:
now_staff: Dein Konto ist jetzt Übertragungsverantwortlicher. now_staff: Dein Konto ist jetzt Übertragungsverantwortlicher.
staff_role_removed: Staff-Rolle von deinem Konto entfernt. staff_role_removed: Staff-Rolle von deinem Konto entfernt.
invite_link_generated: "Einladungslink erstellt (7 Tage gültig)" invite_link_generated: "Einladungslink erstellt (7 Tage gültig)"
invite_email_sent: "Einladung an %{email} gesendet (Link 7 Tage gültig)"
invite_email_failed: "Einladung erstellt, aber die E-Mail an %{email} konnte nicht gesendet werden. Kopiere und teile den Link unten."
access_revoked: Zugriff widerrufen access_revoked: Zugriff widerrufen
invitation_canceled: Einladung storniert invitation_canceled: Einladung storniert
youtube_disconnected: YouTube-Kanal des Vereins getrennt youtube_disconnected: YouTube-Kanal des Vereins getrennt
+14 -5
View File
@@ -77,12 +77,12 @@ en:
first_team_name_label: Team name first_team_name_label: Team name
first_team_name_placeholder: "e.g. Under 15" first_team_name_placeholder: "e.g. Under 15"
plan_label: "Starting plan (for the whole club)" plan_label: "Starting plan (for the whole club)"
plan_free: "Free — 1 broadcast manager per team, 1 live stream" plan_free: "Free — 1 transmitter account per team, 1 live stream"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Premium billing interval_label: Premium billing
interval_yearly: "Yearly — €40/year (Light) or €200/year (Full)" interval_yearly: "Yearly — €59/year (Light) or €199/year (Full)"
interval_monthly: "Monthly — €5/month (Light) or €20/month (Full)" interval_monthly: "Monthly — €7.90/month (Light) or €24.90/month (Full)"
submit: Create club submit: Create club
billing: billing:
title: "Subscription — %{name}" title: "Subscription — %{name}"
@@ -165,8 +165,8 @@ en:
self_assign_submit: I'll do it — broadcast manager self_assign_submit: I'll do it — broadcast manager
invite_other_heading: Invite another email invite_other_heading: Invite another email
email_label: Broadcast manager's email email_label: Broadcast manager's email
submit: Generate access link submit: Send invite by email
share_hint: "Share (valid for 7 days):" share_hint: "Link also sent by email (valid for 7 days):"
billing: billing:
title: "Subscription — %{name}" title: "Subscription — %{name}"
team_label: "Team:" team_label: "Team:"
@@ -203,6 +203,10 @@ en:
revoke_confirm: "Revoke access for %{name}?" revoke_confirm: "Revoke access for %{name}?"
no_staff: No broadcast managers yet. no_staff: No broadcast managers yet.
invite_someone: Invite someone invite_someone: Invite someone
invite_form_heading: Invite an account to broadcast
invite_form_hint: "This is not the roster (players/on-court staff). Enter the email: we send the link automatically; you can also copy and share it."
invite_once_note: "If needed, copy the link below for a second share (WhatsApp, SMS)."
copied: Copied
pending_invitations_heading: Pending invitations pending_invitations_heading: Pending invitations
col_email_short: Email col_email_short: Email
col_expires: Expires col_expires: Expires
@@ -418,6 +422,9 @@ en:
upgrade_info_line: "Upgrade (higher plan or switch to annual billing): the difference is charged immediately to the saved Stripe card." 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." 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." already_requested_line: "You already requested %{plan} from %{when}: until then %{current_plan} stays active."
prices:
yearly: "%{amount}/year"
monthly: "%{amount}/month"
regia: regia:
meta_title_fallback: "Control room — Match Live TV" meta_title_fallback: "Control room — Match Live TV"
title: "Control room — %{home} vs %{away}" title: "Control room — %{home} vs %{away}"
@@ -628,6 +635,8 @@ en:
now_staff: Your account is now a broadcast manager. now_staff: Your account is now a broadcast manager.
staff_role_removed: Staff role removed from your account. staff_role_removed: Staff role removed from your account.
invite_link_generated: "Invite link generated (valid for 7 days)" invite_link_generated: "Invite link generated (valid for 7 days)"
invite_email_sent: "Invite sent to %{email} (link valid for 7 days)"
invite_email_failed: "Invite created, but the email to %{email} could not be sent. Copy and share the link below."
access_revoked: Access revoked access_revoked: Access revoked
invitation_canceled: Invitation canceled invitation_canceled: Invitation canceled
youtube_disconnected: Club YouTube channel disconnected youtube_disconnected: Club YouTube channel disconnected
+14 -5
View File
@@ -82,12 +82,12 @@ es:
first_team_name_label: Nombre del equipo first_team_name_label: Nombre del equipo
first_team_name_placeholder: "ej. Sub-15" first_team_name_placeholder: "ej. Sub-15"
plan_label: "Plan inicial (para todo el club)" plan_label: "Plan inicial (para todo el club)"
plan_free: "Free — 1 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_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Facturación premium interval_label: Facturación premium
interval_yearly: "Anual — 40 €/año (Light) o 200 €/año (Full)" interval_yearly: "Anual — 59 €/año (Light) o 199 €/año (Full)"
interval_monthly: "Mensual — 5 €/mes (Light) o 20 €/mes (Full)" interval_monthly: "Mensual — 7,90 €/mes (Light) o 24,90 €/mes (Full)"
submit: Crear club submit: Crear club
billing: billing:
title: "Suscripción — %{name}" title: "Suscripción — %{name}"
@@ -170,8 +170,8 @@ es:
self_assign_submit: Yo me encargo — responsable de transmisión self_assign_submit: Yo me encargo — responsable de transmisión
invite_other_heading: Invitar a otro correo invite_other_heading: Invitar a otro correo
email_label: Correo del responsable de transmisión email_label: Correo del responsable de transmisión
submit: Generar enlace de acceso submit: Enviar invitación por email
share_hint: "Comparte (válido 7 días):" share_hint: "Enlace también enviado por email (válido 7 días):"
billing: billing:
title: "Suscripción — %{name}" title: "Suscripción — %{name}"
team_label: "Equipo:" team_label: "Equipo:"
@@ -208,6 +208,10 @@ es:
revoke_confirm: "¿Revocar el acceso a %{name}?" revoke_confirm: "¿Revocar el acceso a %{name}?"
no_staff: Aún no hay responsables de transmisión. no_staff: Aún no hay responsables de transmisión.
invite_someone: Invitar a alguien invite_someone: Invitar a alguien
invite_form_heading: Invitar una cuenta a transmitir
invite_form_hint: "No es la plantilla (jugadores/staff en pista). Introduce el email: enviamos el enlace automáticamente; también puedes copiarlo y compartirlo."
invite_once_note: "Si hace falta, copia también el enlace abajo para un segundo envío (WhatsApp, SMS)."
copied: Copiado
pending_invitations_heading: Invitaciones pendientes pending_invitations_heading: Invitaciones pendientes
col_email_short: Correo col_email_short: Correo
col_expires: Caduca col_expires: Caduca
@@ -423,6 +427,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." 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." 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}." 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: regia:
meta_title_fallback: "Control — Match Live TV" meta_title_fallback: "Control — Match Live TV"
title: "Control — %{home} vs %{away}" title: "Control — %{home} vs %{away}"
@@ -633,6 +640,8 @@ es:
now_staff: Tu cuenta ahora es responsable de transmisión. now_staff: Tu cuenta ahora es responsable de transmisión.
staff_role_removed: Rol de staff eliminado de tu cuenta. staff_role_removed: Rol de staff eliminado de tu cuenta.
invite_link_generated: "Enlace de invitación generado (válido 7 días)" invite_link_generated: "Enlace de invitación generado (válido 7 días)"
invite_email_sent: "Invitación enviada a %{email} (enlace válido 7 días)"
invite_email_failed: "Invitación creada, pero no se pudo enviar el email a %{email}. Copia y comparte el enlace de abajo."
access_revoked: Acceso revocado access_revoked: Acceso revocado
invitation_canceled: Invitación anulada invitation_canceled: Invitación anulada
youtube_disconnected: Canal de YouTube del club desconectado youtube_disconnected: Canal de YouTube del club desconectado
+14 -5
View File
@@ -82,12 +82,12 @@ fr:
first_team_name_label: Nom de l'équipe first_team_name_label: Nom de l'équipe
first_team_name_placeholder: "ex. Under 15" first_team_name_placeholder: "ex. Under 15"
plan_label: "Forfait de départ (pour tout le club)" plan_label: "Forfait de départ (pour tout le club)"
plan_free: "Gratuit — 1 responsable diffusion par équipe, 1 direct" plan_free: "Gratuit — 1 compte diffuseur par équipe, 1 direct"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Facturation premium interval_label: Facturation premium
interval_yearly: "Annuel — 40 €/an (Light) ou 200 €/an (Full)" interval_yearly: "Annuel — 59 €/an (Light) ou 199 €/an (Full)"
interval_monthly: "Mensuel — 5 €/mois (Light) ou 20 €/mois (Full)" interval_monthly: "Mensuel — 7,90 €/mois (Light) ou 24,90 €/mois (Full)"
submit: Créer le club submit: Créer le club
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
@@ -170,8 +170,8 @@ fr:
self_assign_submit: C'est moi — responsable diffusion self_assign_submit: C'est moi — responsable diffusion
invite_other_heading: Inviter un autre e-mail invite_other_heading: Inviter un autre e-mail
email_label: E-mail du responsable diffusion email_label: E-mail du responsable diffusion
submit: Générer le lien d'accès submit: Envoyer l'invitation par e-mail
share_hint: "Partage (valable 7 jours) :" share_hint: "Lien aussi envoyé par e-mail (valable 7 jours) :"
billing: billing:
title: "Abonnement — %{name}" title: "Abonnement — %{name}"
team_label: "Équipe :" team_label: "Équipe :"
@@ -208,6 +208,10 @@ fr:
revoke_confirm: "Révoquer l'accès de %{name} ?" revoke_confirm: "Révoquer l'accès de %{name} ?"
no_staff: Aucun responsable diffusion pour le moment. no_staff: Aucun responsable diffusion pour le moment.
invite_someone: Inviter quelqu'un invite_someone: Inviter quelqu'un
invite_form_heading: Inviter un compte à diffuser
invite_form_hint: "Ce nest pas leffectif (joueurs/staff sur le terrain). Saisissez le-mail : nous envoyons le lien automatiquement ; vous pouvez aussi le copier et le partager."
invite_once_note: "Si besoin, copiez aussi le lien ci-dessous pour un second envoi (WhatsApp, SMS)."
copied: Copié
pending_invitations_heading: Invitations en attente pending_invitations_heading: Invitations en attente
col_email_short: E-mail col_email_short: E-mail
col_expires: Expire col_expires: Expire
@@ -423,6 +427,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." 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." 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." 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: regia:
meta_title_fallback: "Régie — Match Live TV" meta_title_fallback: "Régie — Match Live TV"
title: "Régie — %{home} vs %{away}" title: "Régie — %{home} vs %{away}"
@@ -633,6 +640,8 @@ fr:
now_staff: Ton compte est maintenant responsable diffusion. now_staff: Ton compte est maintenant responsable diffusion.
staff_role_removed: Rôle staff retiré de ton compte. staff_role_removed: Rôle staff retiré de ton compte.
invite_link_generated: "Lien d'invitation généré (valable 7 jours)" invite_link_generated: "Lien d'invitation généré (valable 7 jours)"
invite_email_sent: "Invitation envoyée à %{email} (lien valable 7 jours)"
invite_email_failed: "Invitation créée, mais l'e-mail à %{email} n'a pas pu être envoyé. Copiez et partagez le lien ci-dessous."
access_revoked: Accès révoqué access_revoked: Accès révoqué
invitation_canceled: Invitation annulée invitation_canceled: Invitation annulée
youtube_disconnected: Chaîne YouTube du club déconnectée youtube_disconnected: Chaîne YouTube du club déconnectée
+35 -5
View File
@@ -77,12 +77,12 @@ it:
first_team_name_label: Nome squadra first_team_name_label: Nome squadra
first_team_name_placeholder: "es. Under 15" first_team_name_placeholder: "es. Under 15"
plan_label: "Piano iniziale (per tutta la società)" plan_label: "Piano iniziale (per tutta la società)"
plan_free: "Free — 1 responsabile trasmissione per squadra, 1 live" plan_free: "Free — 1 account trasmettitore per squadra, 1 live"
plan_light: Premium Light plan_light: Premium Light
plan_full: Premium Full plan_full: Premium Full
interval_label: Fatturazione premium interval_label: Fatturazione premium
interval_yearly: "Annuale — €40/anno (Light) o €200/anno (Full)" interval_yearly: "Annuale — €59/anno (Light) o €199/anno (Full)"
interval_monthly: "Mensile — €5/mese (Light) o €20/mese (Full)" interval_monthly: "Mensile — €7,90/mese (Light) o €24,90/mese (Full)"
submit: Crea società submit: Crea società
billing: billing:
title: "Abbonamento — %{name}" title: "Abbonamento — %{name}"
@@ -165,8 +165,8 @@ it:
self_assign_submit: Sono io — responsabile trasmissione self_assign_submit: Sono io — responsabile trasmissione
invite_other_heading: Invita un'altra email invite_other_heading: Invita un'altra email
email_label: Email del responsabile trasmissione email_label: Email del responsabile trasmissione
submit: Genera link di accesso submit: Invia invito per email
share_hint: "Condividi (valido 7 giorni):" share_hint: "Link inviato anche per email (valido 7 giorni):"
billing: billing:
title: "Abbonamento — %{name}" title: "Abbonamento — %{name}"
team_label: "Squadra:" team_label: "Squadra:"
@@ -203,6 +203,10 @@ it:
revoke_confirm: "Revocare l'accesso a %{name}?" revoke_confirm: "Revocare l'accesso a %{name}?"
no_staff: Nessun responsabile trasmissione. no_staff: Nessun responsabile trasmissione.
invite_someone: Invita qualcuno invite_someone: Invita qualcuno
invite_form_heading: Invita un account a trasmettere
invite_form_hint: "Non è lorganico (giocatori/staff in campo). Inserisci lemail: inviamo il link automaticamente; puoi anche copiarlo e condividerlo."
invite_once_note: "Se serve, copia il link anche qui sotto per un secondo invio (WhatsApp, SMS)."
copied: Copiato
pending_invitations_heading: Inviti in attesa pending_invitations_heading: Inviti in attesa
col_email_short: Email col_email_short: Email
col_expires: Scade col_expires: Scade
@@ -330,6 +334,7 @@ it:
contact_for_free: "Per passare al piano Free, contatta il supporto." contact_for_free: "Per passare al piano Free, contatta il supporto."
stripe_not_configured: Stripe non configurato stripe_not_configured: Stripe non configurato
stripe_prices_not_configured: Prezzi Stripe non configurati stripe_prices_not_configured: Prezzi Stripe non configurati
bank_transfer_not_configured: Bonifico non ancora configurato
complete_billing_prefix: Completa i dati di fatturazione prima di attivare un piano premium. complete_billing_prefix: Completa i dati di fatturazione prima di attivare un piano premium.
profile_incomplete_default: Completa i dati di fatturazione prima di attivare un piano premium. profile_incomplete_default: Completa i dati di fatturazione prima di attivare un piano premium.
profile_missing: "Mancano: %{fields}." profile_missing: "Mancano: %{fields}."
@@ -392,6 +397,7 @@ it:
end_of_period: fine periodo end_of_period: fine periodo
next_renewal: prossimo rinnovo next_renewal: prossimo rinnovo
next_renewal_label: "Prossimo rinnovo: %{date}" next_renewal_label: "Prossimo rinnovo: %{date}"
bank_transfer_until_html: "Pagato con bonifico, valido fino al <strong>%{date}</strong> (nessun rinnovo automatico)."
plan_change_info: plan_change_info:
trigger_label: Info cambio piano e fatturazione trigger_label: Info cambio piano e fatturazione
title: Cambio piano e fatturazione title: Cambio piano e fatturazione
@@ -418,6 +424,26 @@ it:
upgrade_info_line: "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe." 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." 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}." already_requested_line: "Hai già richiesto %{plan} dal %{when}: fino ad allora resta attivo %{current_plan}."
prices:
yearly: "%{amount}/anno"
monthly: "%{amount}/mese"
bank_transfer:
quoted_badge: Prezzo concordato
quoted_interval:
yearly: fatturazione annuale, solo bonifico
monthly: fatturazione mensile, solo bonifico
quoted_other: "Per questa società è attivo un prezzo concordato su un altro piano."
pay_button: Paga con bonifico
pay_button_with_price: "Paga con bonifico — %{price}"
or_label: oppure
pending_title: Bonifico in attesa di conferma
pending_body_html: "Hai richiesto <strong>%{plan}</strong> (%{price}). Il piano si attiverà dopo la conferma del pagamento. Causale: <strong>%{causal}</strong>."
pending_proof_html: "Invia la distinta a %{email_link}."
quote_banner_title: Prezzo concordato
quote_banner_body_html: "Per questa società vale <strong>%{plan}</strong> a <strong>%{price}</strong>. Il pagamento avviene solo con bonifico (non con carta)."
quote_note: "Nota commerciale: %{note}"
hint: "Il piano resta quello attuale finché Match Live TV non conferma l'accredito."
renewal_html: "Con il bonifico l'abbonamento non si rinnova da solo: alla scadenza torna Free, salvo nuovo bonifico o rinnovo da parte nostra."
regia: regia:
meta_title_fallback: "Regia — Match Live TV" meta_title_fallback: "Regia — Match Live TV"
title: "Regia — %{home} vs %{away}" title: "Regia — %{home} vs %{away}"
@@ -620,6 +646,8 @@ it:
subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free." subscription_canceled: "Abbonamento disdetto. Resta attivo fino al %{date}; da quel giorno passerai al piano Free."
stripe_error: "Errore Stripe: %{message}" stripe_error: "Errore Stripe: %{message}"
invoice_pdf_unavailable: PDF fattura non disponibile. invoice_pdf_unavailable: PDF fattura non disponibile.
bank_transfer_requested: Istruzioni per il bonifico inviate all'email di fatturazione. Il piano si attiverà dopo la conferma del pagamento.
quote_checkout_denied: "Per questa società è attivo un prezzo concordato: usa il pagamento con bonifico."
club_recordings: club_recordings:
not_authorized: Non autorizzato not_authorized: Non autorizzato
teams: teams:
@@ -628,6 +656,8 @@ it:
now_staff: Il tuo account è ora responsabile trasmissione. now_staff: Il tuo account è ora responsabile trasmissione.
staff_role_removed: Ruolo staff rimosso dal tuo account. staff_role_removed: Ruolo staff rimosso dal tuo account.
invite_link_generated: "Link invito generato (valido 7 giorni)" invite_link_generated: "Link invito generato (valido 7 giorni)"
invite_email_sent: "Invito inviato a %{email} (link valido 7 giorni)"
invite_email_failed: "Invito creato, ma lemail a %{email} non è partita. Copia e condividi il link qui sotto."
access_revoked: Accesso revocato access_revoked: Accesso revocato
invitation_canceled: Invito annullato invitation_canceled: Invito annullato
youtube_disconnected: Canale YouTube della società scollegato youtube_disconnected: Canale YouTube della società scollegato
+13
View File
@@ -31,6 +31,19 @@ de:
open_text: "Öffnen: %{url}" open_text: "Öffnen: %{url}"
archive_text: "Archiv: %{url}" archive_text: "Archiv: %{url}"
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
transmission_invite:
subject: "Einladung zum Übertragen — %{team} (%{club})"
hello: Hallo,
body_html: "<strong>%{inviter}</strong> hat dich als Übertragungsverantwortliche/n für <strong>%{team}</strong> (%{club}) auf Match Live TV eingeladen."
body_text: "%{inviter} hat dich als Übertragungsverantwortliche/n für %{team} (%{club}) auf Match Live TV eingeladen."
cta: Einladung annehmen
link_html: <a href="%{url}">Einladung annehmen</a>
link_text: "Öffne diesen Link zum Annehmen:"
link_fallback: "Falls der Button nicht funktioniert, öffne diesen Link:"
steps: "Melde dich mit derselben E-Mail an oder registriere dich, nimm die Einladung an und starte Übertragungen in der Match-Live-TV-App."
expiry: "Der Link ist gültig bis %{date}."
ignore: Wenn du diese Nachricht nicht erwartet hast, kannst du sie ignorieren.
footer: "Match Live TV — %{email}"
invoice: invoice:
subject: "Rechnung %{number} — Match Live TV" subject: "Rechnung %{number} — Match Live TV"
attachment_prefix: rechnung attachment_prefix: rechnung
+44
View File
@@ -31,6 +31,19 @@ en:
open_text: "Open: %{url}" open_text: "Open: %{url}"
archive_text: "Archive: %{url}" archive_text: "Archive: %{url}"
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
transmission_invite:
subject: "Broadcast invite — %{team} (%{club})"
hello: Hi,
body_html: "<strong>%{inviter}</strong> invited you as a broadcast manager for <strong>%{team}</strong> (%{club}) on Match Live TV."
body_text: "%{inviter} invited you as a broadcast manager for %{team} (%{club}) on Match Live TV."
cta: Accept the invite
link_html: <a href="%{url}">Accept the invite</a>
link_text: "Open this link to accept:"
link_fallback: "If the button does not work, open this link:"
steps: "Sign in or register with this same email, accept the invite, then start streams from the Match Live TV app."
expiry: "The link is valid until %{date}."
ignore: If you were not expecting this message, you can ignore it.
footer: "Match Live TV — %{email}"
invoice: invoice:
subject: "Invoice %{number} — Match Live TV" subject: "Invoice %{number} — Match Live TV"
attachment_prefix: invoice attachment_prefix: invoice
@@ -41,3 +54,34 @@ en:
also_text: It is also available under Subscription on the Match Live TV site. also_text: It is also available under Subscription on the Match Live TV site.
thanks: Thanks, thanks: Thanks,
signoff: Match Live TV signoff: Match Live TV
invoice_activated:
subject: "Plan %{plan} is active — invoice %{number}"
hello: Hello,
period_fallback: the end of the period
body_html: "we received your bank transfer: the <strong>%{plan}</strong> plan is now active for <strong>%{club}</strong> until <strong>%{until_date}</strong>. Invoice <strong>%{number}</strong> for <strong>%{amount}</strong> is attached."
body_text: "we received your bank transfer: the %{plan} plan is now active for %{club} until %{until_date}. Invoice %{number} for %{amount} is attached."
bank_transfer:
thanks: Thanks,
signoff: Match Live TV
instructions:
subject: "Bank transfer instructions — %{plan}"
hello: Hello,
intro_html: "you requested the <strong>%{plan}</strong> plan (%{price}) for <strong>%{club}</strong> with a bank transfer."
intro_text: "you requested the %{plan} plan (%{price}) for %{club} with a bank transfer."
not_active_yet: The plan will be activated only after Match Live TV confirms the payment. Until then your current plan stays active.
iban_label: "IBAN:"
holder_label: "Account holder:"
bank_label: "Bank:"
bic_label: "BIC/SWIFT:"
amount_label: "Amount:"
causal_label: "Payment reference:"
proof_html: "After paying, send the receipt to <strong>%{email}</strong>."
proof_text: "After paying, send the receipt to %{email}."
plan_activated:
subject: "Plan %{plan} is active — Match Live TV"
hello: Hello,
period_fallback: the end of the period
body_html: "we received your bank transfer: the <strong>%{plan}</strong> plan is now active for <strong>%{club}</strong> until <strong>%{until_date}</strong>. The invoice will follow by email shortly."
body_text: "we received your bank transfer: the %{plan} plan is now active for %{club} until %{until_date}. The invoice will follow by email shortly."
invoice_later: You can already use the plan from the Subscription section of your account.
+13
View File
@@ -31,6 +31,19 @@ es:
open_text: "Abrir: %{url}" open_text: "Abrir: %{url}"
archive_text: "Archivo: %{url}" archive_text: "Archivo: %{url}"
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
transmission_invite:
subject: "Invitación a transmitir — %{team} (%{club})"
hello: Hola,
body_html: "<strong>%{inviter}</strong> te ha invitado como responsable de transmisión de <strong>%{team}</strong> (%{club}) en Match Live TV."
body_text: "%{inviter} te ha invitado como responsable de transmisión de %{team} (%{club}) en Match Live TV."
cta: Aceptar la invitación
link_html: <a href="%{url}">Aceptar la invitación</a>
link_text: "Abre este enlace para aceptar:"
link_fallback: "Si el botón no funciona, abre este enlace:"
steps: "Inicia sesión o regístrate con este mismo email, acepta la invitación y lanza las directos desde la app Match Live TV."
expiry: "El enlace es válido hasta el %{date}."
ignore: Si no esperabas este mensaje, puedes ignorarlo.
footer: "Match Live TV — %{email}"
invoice: invoice:
subject: "Factura %{number} — Match Live TV" subject: "Factura %{number} — Match Live TV"
attachment_prefix: factura attachment_prefix: factura
+13
View File
@@ -31,6 +31,19 @@ fr:
open_text: "Ouvrir : %{url}" open_text: "Ouvrir : %{url}"
archive_text: "Archive : %{url}" archive_text: "Archive : %{url}"
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
transmission_invite:
subject: "Invitation à diffuser — %{team} (%{club})"
hello: Bonjour,
body_html: "<strong>%{inviter}</strong> vous a invité comme responsable diffusion pour <strong>%{team}</strong> (%{club}) sur Match Live TV."
body_text: "%{inviter} vous a invité comme responsable diffusion pour %{team} (%{club}) sur Match Live TV."
cta: Accepter linvitation
link_html: <a href="%{url}">Accepter linvitation</a>
link_text: "Ouvrez ce lien pour accepter :"
link_fallback: "Si le bouton ne fonctionne pas, ouvrez ce lien :"
steps: "Connectez-vous ou inscrivez-vous avec cette même adresse e-mail, acceptez linvitation, puis lancez les directs depuis lapp Match Live TV."
expiry: "Le lien est valable jusquau %{date}."
ignore: Si vous nattendiez pas ce message, vous pouvez lignorer.
footer: "Match Live TV — %{email}"
invoice: invoice:
subject: "Facture %{number} — Match Live TV" subject: "Facture %{number} — Match Live TV"
attachment_prefix: facture attachment_prefix: facture
+44
View File
@@ -31,6 +31,19 @@ it:
open_text: "Apri: %{url}" open_text: "Apri: %{url}"
archive_text: "Archivio: %{url}" archive_text: "Archivio: %{url}"
footer: "Match Live TV — %{email}" footer: "Match Live TV — %{email}"
transmission_invite:
subject: "Invito a trasmettere — %{team} (%{club})"
hello: Ciao,
body_html: "<strong>%{inviter}</strong> ti ha invitato come responsabile trasmissione per <strong>%{team}</strong> (%{club}) su Match Live TV."
body_text: "%{inviter} ti ha invitato come responsabile trasmissione per %{team} (%{club}) su Match Live TV."
cta: Accetta linvito
link_html: <a href="%{url}">Accetta linvito</a>
link_text: "Apri questo link per accettare:"
link_fallback: "Se il pulsante non funziona, apri questo link:"
steps: "Accedi o registrati con questa stessa email, accetta linvito e avvia le dirette dallapp Match Live TV."
expiry: "Il link è valido fino al %{date}."
ignore: Se non ti aspettavi questo messaggio, puoi ignorarlo.
footer: "Match Live TV — %{email}"
invoice: invoice:
subject: "Fattura %{number} — Match Live TV" subject: "Fattura %{number} — Match Live TV"
attachment_prefix: fattura attachment_prefix: fattura
@@ -41,3 +54,34 @@ it:
also_text: È disponibile anche in Abbonamento sul sito Match Live TV. also_text: È disponibile anche in Abbonamento sul sito Match Live TV.
thanks: Grazie, thanks: Grazie,
signoff: Match Live TV signoff: Match Live TV
invoice_activated:
subject: "Piano %{plan} attivo — fattura %{number}"
hello: Buongiorno,
period_fallback: fine periodo
body_html: "abbiamo ricevuto il bonifico: il piano <strong>%{plan}</strong> è attivo per <strong>%{club}</strong> fino al <strong>%{until_date}</strong>. In allegato la fattura <strong>%{number}</strong> di <strong>%{amount}</strong>."
body_text: "abbiamo ricevuto il bonifico: il piano %{plan} è attivo per %{club} fino al %{until_date}. In allegato la fattura %{number} di %{amount}."
bank_transfer:
thanks: Grazie,
signoff: Match Live TV
instructions:
subject: "Istruzioni per il bonifico — %{plan}"
hello: Buongiorno,
intro_html: "hai richiesto l'abbonamento <strong>%{plan}</strong> (%{price}) per <strong>%{club}</strong> con pagamento tramite bonifico."
intro_text: "hai richiesto l'abbonamento %{plan} (%{price}) per %{club} con pagamento tramite bonifico."
not_active_yet: Il piano verrà attivato solo dopo la conferma del pagamento da parte di Match Live TV. Fino ad allora resta attivo il piano attuale.
iban_label: "IBAN:"
holder_label: "Intestatario:"
bank_label: "Banca:"
bic_label: "BIC/SWIFT:"
amount_label: "Importo:"
causal_label: "Causale:"
proof_html: "Dopo il pagamento invia la distinta o la ricevuta a <strong>%{email}</strong>."
proof_text: "Dopo il pagamento invia la distinta o la ricevuta a %{email}."
plan_activated:
subject: "Piano %{plan} attivo — Match Live TV"
hello: Buongiorno,
period_fallback: fine periodo
body_html: "abbiamo ricevuto il bonifico: il piano <strong>%{plan}</strong> è attivo per <strong>%{club}</strong> fino al <strong>%{until_date}</strong>. La fattura ti arriverà a breve via email."
body_text: "abbiamo ricevuto il bonifico: il piano %{plan} è attivo per %{club} fino al %{until_date}. La fattura ti arriverà a breve via email."
invoice_later: Puoi già usare le funzioni del piano dalla sezione Abbonamento del tuo account.
+86 -31
View File
@@ -2,32 +2,80 @@ de:
pages: pages:
features: features:
meta_title: "Streaming-Funktionen für Jugendspiele — Match Live TV" 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." 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."
title: Funktionen hero_title: "Die Livestreams Ihres Vereins. Ohne geteilte Passwörter, ohne komplizierte Technik."
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." 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."
staff_section_title: Für Verein und Team hero_alt: Smartphone auf Stativ für den Livestream und zweites Handy für den Spielstand in der Halle
staff_invite_title: Einladungen mit eigener E-Mail flow_aria: Ablauf der Übertragung
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." flow_phone: Ein Smartphone
staff_access_title: Zugriffsverwaltung flow_account: Ein persönliches Konto
staff_access_body: Sie können einem Helfer den Zugriff entziehen oder eine noch offene Einladung vom Team-Dashboard aus stornieren. flow_live: Sie sind live
staff_plans_title: Maßgeschneiderte Pläne how_title: Von der Halle zu den Zuschauern in wenigen Minuten
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." how_1_title: Spiel anlegen
live_section_title: Auf dem Platz und live how_1_body: Der Verein bereitet das Spiel mit Team, Gegner, Datum und Uhrzeit vor.
live_phone_title: Vom Handy filmen how_2_title: App öffnen
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." how_2_body: Die beauftragte Person meldet sich in der Match-Live-TV-App mit dem eigenen persönlichen Konto an.
live_score_title: Spielstand vom zweiten Handy how_3_title: Livestream starten
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." how_3_body: Ein Smartphone reicht. Keine spezielle Ausrüstung und keine komplizierte Einrichtung.
live_link_title: Link für Familien und Fans how_4_title: Spielstand führen
live_link_body: "Teilen Sie einen Link: Eltern, Großeltern und Freunde schauen im Browser von Handy oder Computer, ohne App-Installation." how_4_body: Eine andere Person kann Ergebnis und Spielstand über einen einfachen Link aktualisieren ohne App und ohne Konto.
live_archive_title: Spielarchiv how_5_title: Replay
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." how_5_body: Nach dem Livestream kann das Spiel gespeichert und als Replay verfügbar gemacht werden je nach Plan.
live_youtube_mltv_title: Auch auf YouTube (Match Live TV) accounts_title: Jeder mit dem eigenen Konto. Das Vereins-Passwort bleibt beim Verein.
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." accounts_lead: Username und Passwort des Hauptkontos müssen nicht mehr geteilt werden. Verantwortliche, Trainer und beauftragte Eltern erhalten einen persönlichen, widerrufbaren Zugang.
live_youtube_club_title: YouTube-Kanal des Vereins accounts_invite_title: Einladen
live_youtube_club_body: "Mit Premium Full können Sie denselben Livestream auch auf den offiziellen YouTube-Kanal des Vereins senden." accounts_invite_body: Der Verein lädt Trainer, Verantwortliche oder Eltern ein, die die Übertragung übernehmen.
live_website_title: Auf der Website des Vereins accounts_transmit_title: Übertragen
live_website_body: "Integration zum Einbetten des Players auf der Vereinswebsite — demnächst mit Premium Full." accounts_transmit_body: Jeder meldet sich in der App mit den eigenen Zugangsdaten an.
cta_signup: Team registrieren 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: pricing:
meta_title: "Preise für Jugendspiel-Streaming — Match Live TV" 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." 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}" club_info_html: "Verein: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Abo verwalten manage_subscription: Abo verwalten
team_title: Pläne für Ihr Team 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." 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-Verantwortliche / Jahr table_staff: Streaming-Konten
table_matches: Gleichzeitige Spiele table_matches: Gleichzeitige Spiele
table_live_mltv: Live auf Match Live TV table_live_mltv: Live auf Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ de:
table_download: Handy-Download table_download: Handy-Download
table_price: Preis table_price: Preis
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/Jahr oder €5/Monat" table_price_note: "Listenpreis %{list} — %{monthly}"
table_price_full_html: "€200/Jahr oder €20/Monat"
table_yes: Ja table_yes: Ja
table_no: Nein table_no: Nein
table_unlimited: Unbegrenzt table_unlimited: Unbegrenzt
@@ -55,8 +102,16 @@ de:
contact_button: Kontaktieren Sie uns contact_button: Kontaktieren Sie uns
plans: plans:
price_alt_html: "oder <strong>%{price}</strong>" 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_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_html: "Spiele: <strong>%{count}</strong> gleichzeitig (ganzer Verein)"
matches_unlimited: unbegrenzt matches_unlimited: unbegrenzt
replay_html: "Replay: %{value}" replay_html: "Replay: %{value}"
+86 -31
View File
@@ -2,32 +2,80 @@ en:
pages: pages:
features: features:
meta_title: "Youth match streaming features — Match Live TV" 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." 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."
title: Features hero_title: "Your club's live streams. No shared passwords, no complicated gear."
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." 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."
staff_section_title: For the club and staff hero_alt: Smartphone on a tripod for the live stream and a second phone for the gym scoreboard
staff_invite_title: Invites with their own email flow_aria: Streaming flow
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." flow_phone: One smartphone
staff_access_title: Access management flow_account: One personal account
staff_access_body: You can remove a volunteer's access or cancel a pending invite from the team dashboard. flow_live: You're live
staff_plans_title: Plans to fit your needs how_title: From the gym to viewers in a few minutes
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. how_1_title: Create the match
live_section_title: On the court and live how_1_body: The club sets up the match with team, opponent, date and time.
live_phone_title: Film from your phone how_2_title: Open the app
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." how_2_body: The assigned person signs into the Match Live TV app with their own personal account.
live_score_title: Score from a second phone how_3_title: Start the stream
live_score_body: "Another volunteer updates sets and points from a second phone: just scan the code shown on the streamer's screen." how_3_body: A smartphone is enough. No dedicated equipment or complicated setup.
live_link_title: Link for families and fans how_4_title: Manage the score
live_link_body: "Share a link: parents, grandparents and friends watch from their phone or computer browser, no app to install." how_4_body: Someone else can update the result and score through a simple link, without installing an app or creating an account.
live_archive_title: Match archive how_5_title: Replay
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." how_5_body: After the stream ends, the match can be kept and made available as a replay, depending on the plan.
live_youtube_mltv_title: Also on YouTube (Match Live TV) accounts_title: Everyone with their own account. The club password stays with the club.
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. accounts_lead: You no longer need to share the main club username and password. Managers, coaches and assigned parents get a personal, revocable login.
live_youtube_club_title: Your club's YouTube channel accounts_invite_title: Invite
live_youtube_club_body: With Premium Full you can send the same stream to your club's official YouTube channel. accounts_invite_body: The club invites coaches, managers or parents assigned to stream.
live_website_title: On the club's website accounts_transmit_title: Stream
live_website_body: Integration to embed the player on the club's website — coming with Premium Full. accounts_transmit_body: Each person signs into the app with their own credentials.
cta_signup: Register your team 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: pricing:
meta_title: "Youth match streaming pricing — Match Live TV" 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." 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}" club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Manage subscription manage_subscription: Manage subscription
team_title: Plans for your team 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." team_lead: "Annual subscription for clubs: transmitter accounts start the broadcast from the app; the score is managed with a shareable link."
table_staff: Streaming staff / year table_staff: Transmitter accounts
table_matches: Concurrent matches table_matches: Concurrent matches
table_live_mltv: Live on Match Live TV table_live_mltv: Live on Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ en:
table_download: Phone download table_download: Phone download
table_price: Price table_price: Price
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/year or €5/month" table_price_note: "list %{list} — %{monthly}"
table_price_full_html: "€200/year or €20/month"
table_yes: "Yes" table_yes: "Yes"
table_no: "No" table_no: "No"
table_unlimited: Unlimited table_unlimited: Unlimited
@@ -55,8 +102,16 @@ en:
contact_button: Contact us contact_button: Contact us
plans: plans:
price_alt_html: "or <strong>%{price}</strong>" 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_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_html: "Matches: <strong>%{count}</strong> concurrent (whole club)"
matches_unlimited: unlimited matches_unlimited: unlimited
replay_html: "Replay: %{value}" replay_html: "Replay: %{value}"
+87 -32
View File
@@ -2,32 +2,80 @@ es:
pages: pages:
features: features:
meta_title: "Funciones de streaming de partidos juveniles — Match Live TV" 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." 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."
title: Funciones hero_title: "Los directos de tu club. Sin contraseñas compartidas, sin equipo complicado."
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." 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."
staff_section_title: Para el club y el staff hero_alt: Smartphone en trípode para el directo y segundo móvil para el marcador en el pabellón
staff_invite_title: Invitaciones con su propio email flow_aria: Flujo de la emisión
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." flow_phone: Un smartphone
staff_access_title: Gestión de accesos flow_account: Una cuenta personal
staff_access_body: Puedes retirar el acceso a un voluntario o cancelar una invitación pendiente desde el panel del equipo. flow_live: Estás en directo
staff_plans_title: Planes a medida how_title: Del pabellón a los espectadores en pocos minutos
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. how_1_title: Crea el partido
live_section_title: En la cancha y en directo how_1_body: El club prepara el partido indicando equipo, rival, fecha y hora.
live_phone_title: Graba desde el móvil how_2_title: Abre la app
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." how_2_body: La persona encargada accede a la app Match Live TV con su propia cuenta personal.
live_score_title: Marcador desde un segundo móvil how_3_title: Empieza el directo
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." how_3_body: Basta el smartphone. Sin equipo dedicado ni configuración complicada.
live_link_title: Enlace para familias y aficionados how_4_title: Gestiona el marcador
live_link_body: "Comparte un enlace: padres, abuelos y amigos miran desde el navegador del móvil o del ordenador, sin instalar apps." how_4_body: Otra persona puede actualizar resultado y marcador con un enlace sencillo, sin instalar apps ni crear una cuenta.
live_archive_title: Archivo de partidos how_5_title: Repetición
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." how_5_body: Al terminar el directo, el partido puede conservarse y quedar disponible como repetición, según el plan elegido.
live_youtube_mltv_title: También en YouTube (Match Live TV) accounts_title: Cada uno con su propia cuenta. La contraseña del club se queda en el club.
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." 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.
live_youtube_club_title: Canal de YouTube del club accounts_invite_title: Invita
live_youtube_club_body: "Con Premium Full puedes enviar el mismo directo también al canal oficial de YouTube del club." accounts_invite_body: El club invita a entrenadores, directivos o padres encargados de la emisión.
live_website_title: En el sitio web del club accounts_transmit_title: Emite
live_website_body: "Integración para incrustar el reproductor en el sitio web del club — próximamente con Premium Full." accounts_transmit_body: Cada uno accede a la app con sus propias credenciales.
cta_signup: Registra tu equipo 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: pricing:
meta_title: "Precios de streaming de partidos juveniles — Match Live TV" 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." 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}" club_info_html: "Club: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gestionar suscripción manage_subscription: Gestionar suscripción
team_title: Planes para tu equipo 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." 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: Responsables de emisión / año table_staff: Cuentas de emisión
table_matches: Partidos simultáneos table_matches: Partidos simultáneos
table_live_mltv: Directo en Match Live TV table_live_mltv: Directo en Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ es:
table_download: Descarga al móvil table_download: Descarga al móvil
table_price: Precio table_price: Precio
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/año o €5/mes" table_price_note: "tarifa %{list} — %{monthly}"
table_price_full_html: "€200/año o €20/mes"
table_yes: "Sí" table_yes: "Sí"
table_no: "No" table_no: "No"
table_unlimited: Ilimitado table_unlimited: Ilimitado
@@ -55,8 +102,16 @@ es:
contact_button: Contáctanos contact_button: Contáctanos
plans: plans:
price_alt_html: "o <strong>%{price}</strong>" price_alt_html: "o <strong>%{price}</strong>"
staff_html: "Responsables de emisión: <strong>%{count}</strong> / año por equipo" price_equiv: "equivale a %{price}"
staff_unlimited: ilimitado 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_html: "Partidos: <strong>%{count}</strong> simultáneos (todo el club)"
matches_unlimited: ilimitados matches_unlimited: ilimitados
replay_html: "Repetición: %{value}" replay_html: "Repetición: %{value}"
+87 -32
View File
@@ -2,32 +2,80 @@ fr:
pages: pages:
features: features:
meta_title: "Fonctionnalités de streaming des matchs jeunes — Match Live TV" 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." 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é."
title: Fonctionnalités hero_title: "Les directs de votre club. Sans mots de passe partagés, sans matériel compliqué."
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." 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."
staff_section_title: Pour le club et le staff hero_alt: Smartphone sur trépied pour le direct et second téléphone pour le score en salle
staff_invite_title: Invitations avec leur propre email flow_aria: Déroulement de la diffusion
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." flow_phone: Un smartphone
staff_access_title: Gestion des accès flow_account: Un compte personnel
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. flow_live: Vous êtes en direct
staff_plans_title: Des offres sur mesure how_title: De la salle aux spectateurs en quelques minutes
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." how_1_title: Créez le match
live_section_title: Sur le terrain et en direct how_1_body: Le club prépare le match en indiquant l'équipe, l'adversaire, la date et l'heure.
live_phone_title: Filmez depuis votre téléphone how_2_title: Ouvrez l'application
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." how_2_body: La personne désignée se connecte à l'application Match Live TV avec son propre compte personnel.
live_score_title: Score depuis un second téléphone how_3_title: Lancez le direct
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." how_3_body: Un smartphone suffit. Pas de matériel dédié ni de configuration compliquée.
live_link_title: Lien pour les familles et supporters how_4_title: Gérez le score
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." 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.
live_archive_title: Archive des matchs how_5_title: Replay
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." how_5_body: À la fin du direct, le match peut être conservé et rendu disponible en replay, selon l'offre choisie.
live_youtube_mltv_title: Aussi sur YouTube (Match Live TV) accounts_title: Chacun avec son propre compte. Le mot de passe du club reste au club.
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." 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.
live_youtube_club_title: Chaîne YouTube du club accounts_invite_title: Invitez
live_youtube_club_body: "Avec Premium Full, vous pouvez diffuser le même direct sur la chaîne YouTube officielle du club." accounts_invite_body: Le club invite les entraîneurs, dirigeants ou parents chargés de la diffusion.
live_website_title: Sur le site du club accounts_transmit_title: Diffusez
live_website_body: "Intégration pour intégrer le lecteur sur le site du club — bientôt disponible avec Premium Full." accounts_transmit_body: Chacun se connecte à l'application avec ses propres identifiants.
cta_signup: Inscrire l'équipe 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: pricing:
meta_title: "Tarifs streaming des matchs jeunes — Match Live TV" 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." 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}" club_info_html: "Club : <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gérer l'abonnement manage_subscription: Gérer l'abonnement
team_title: Offres pour votre équipe 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." 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: Responsables de diffusion / an table_staff: Comptes diffuseurs
table_matches: Matchs simultanés table_matches: Matchs simultanés
table_live_mltv: Direct sur Match Live TV table_live_mltv: Direct sur Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ fr:
table_download: Téléchargement téléphone table_download: Téléchargement téléphone
table_price: Prix table_price: Prix
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/an ou €5/mois" table_price_note: "tarif %{list} — %{monthly}"
table_price_full_html: "€200/an ou €20/mois"
table_yes: Oui table_yes: Oui
table_no: Non table_no: Non
table_unlimited: Illimité table_unlimited: Illimité
@@ -55,8 +102,16 @@ fr:
contact_button: Contactez-nous contact_button: Contactez-nous
plans: plans:
price_alt_html: "ou <strong>%{price}</strong>" price_alt_html: "ou <strong>%{price}</strong>"
staff_html: "Responsables de diffusion : <strong>%{count}</strong> / an par équipe" price_equiv: "soit %{price}"
staff_unlimited: illimité 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_html: "Matchs : <strong>%{count}</strong> simultanés (tout le club)"
matches_unlimited: illimités matches_unlimited: illimités
replay_html: "Replay : %{value}" replay_html: "Replay : %{value}"
+87 -32
View File
@@ -2,32 +2,80 @@ it:
pages: pages:
features: features:
meta_title: "Funzionalità streaming partite giovanili — Match Live TV" 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." 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."
title: Funzionalità hero_title: "Le dirette della tua società. Senza password condivise, senza attrezzatura complicata."
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." 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."
staff_section_title: Per la società e lo staff hero_alt: Smartphone su treppiede per la diretta e secondo telefono per il punteggio in palestra
staff_invite_title: Inviti con la propria email flow_aria: Flusso della trasmissione
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." flow_phone: Uno smartphone
staff_access_title: Gestione accessi flow_account: Un account personale
staff_access_body: Puoi togliere l'accesso a un volontario o annullare un invito ancora in sospeso dalla dashboard della squadra. flow_live: Sei in diretta
staff_plans_title: Piani su misura how_title: Dalla palestra agli spettatori in pochi minuti
staff_plans_body: Con Free, Premium Light o Premium Full aumenti quante persone possono aiutare in diretta e quante partite possono andare in parallelo. how_1_title: Crea la partita
live_section_title: In campo e in diretta how_1_body: La società prepara la partita indicando squadra, avversario, data e ora.
live_phone_title: Filma dal telefono how_2_title: Apri l'app
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." how_2_body: L'incaricato accede dall'app Match Live TV con il proprio account personale.
live_score_title: Punteggio da un secondo telefono how_3_title: Inizia la diretta
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." how_3_body: Basta lo smartphone. Nessuna attrezzatura dedicata o configurazione complicata.
live_link_title: Link per famiglie e tifosi how_4_title: Gestisci il punteggio
live_link_body: "Condividi un link: genitori, nonni e amici guardano dal browser del telefono o del computer, senza installare app." how_4_body: Un'altra persona può aggiornare risultato e punteggio tramite un semplice link, senza installare app e senza creare un account.
live_archive_title: Archivio partite how_5_title: Replay
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." how_5_body: Al termine della diretta la partita può essere conservata e resa disponibile come replay, in base al piano scelto.
live_youtube_mltv_title: Anche su YouTube (Match Live TV) accounts_title: Ognuno con il proprio account. La password della società resta alla società.
live_youtube_mltv_body: Con Premium Light la diretta è visibile anche sul canale YouTube Match Live TV, oltre al link sul sito. accounts_lead: Non serve più condividere username e password dell'account principale. Dirigenti, allenatori e genitori incaricati ricevono un accesso personale e revocabile.
live_youtube_club_title: Canale YouTube della società accounts_invite_title: Invita
live_youtube_club_body: Con Premium Full puoi mandare la stessa diretta anche sul canale YouTube ufficiale del club. accounts_invite_body: La società invita allenatori, dirigenti o genitori incaricati della trasmissione.
live_website_title: Sul sito della società accounts_transmit_title: Trasmetti
live_website_body: Integrazione per incorporare il player sul sito del club — in arrivo con Premium Full. accounts_transmit_body: Ognuno accede all'app con le proprie credenziali personali.
cta_signup: Registra la squadra 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: pricing:
meta_title: "Prezzi streaming partite giovanili — Match Live TV" 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." 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}" club_info_html: "Società: <strong>%{name}</strong> · %{billing_link}"
manage_subscription: Gestisci abbonamento manage_subscription: Gestisci abbonamento
team_title: Piani per la tua squadra 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." team_lead: "Abbonamento annuale per società: gli account trasmettitori avviano la diretta dall'app; il punteggio si gestisce con un link condivisibile."
table_staff: Responsabili trasmissione / anno table_staff: Account trasmettitori
table_matches: Partite in contemporanea table_matches: Partite in contemporanea
table_live_mltv: Live su Match Live TV table_live_mltv: Live su Match Live TV
table_youtube: YouTube table_youtube: YouTube
@@ -44,8 +92,7 @@ it:
table_download: Download telefono table_download: Download telefono
table_price: Prezzo table_price: Prezzo
table_price_free: "€0" table_price_free: "€0"
table_price_light_html: "€40/anno o €5/mese" table_price_note: "listino %{list} — %{monthly}"
table_price_full_html: "€200/anno o €20/mese"
table_yes: "Sì" table_yes: "Sì"
table_no: "No" table_no: "No"
table_unlimited: Illimitato table_unlimited: Illimitato
@@ -55,8 +102,16 @@ it:
contact_button: Contattaci contact_button: Contattaci
plans: plans:
price_alt_html: "oppure <strong>%{price}</strong>" price_alt_html: "oppure <strong>%{price}</strong>"
staff_html: "Responsabili trasmissione: <strong>%{count}</strong> / anno per squadra" price_equiv: "equivale a %{price}"
staff_unlimited: illimitato 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_html: "Partite: <strong>%{count}</strong> in contemporanea (tutta la società)"
matches_unlimited: illimitate matches_unlimited: illimitate
replay_html: "Replay: %{value}" replay_html: "Replay: %{value}"
+7
View File
@@ -43,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." 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_signup: Team registrieren
cta_live: Live-Spiele ansehen 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 features_aria: Wichtige Vorteile
feature_stable: Stabile Streams feature_stable: Stabile Streams
feature_archive: Sicheres Archiv feature_archive: Sicheres Archiv
+7
View File
@@ -43,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. 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_signup: Register your team
cta_live: Watch live matches 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 features_aria: Key benefits
feature_stable: Reliable streams feature_stable: Reliable streams
feature_archive: Secure archive feature_archive: Secure archive
+7
View File
@@ -43,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. 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_signup: Registra tu equipo
cta_live: Ver directos 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 features_aria: Ventajas principales
feature_stable: Directos estables feature_stable: Directos estables
feature_archive: Archivo seguro feature_archive: Archivo seguro
+7
View File
@@ -43,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. 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_signup: Inscrire votre équipe
cta_live: Voir les directs 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 features_aria: Avantages principaux
feature_stable: Directs stables feature_stable: Directs stables
feature_archive: Archive sécurisée feature_archive: Archive sécurisée
+7
View File
@@ -43,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. 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_signup: Registra la tua squadra
cta_live: Guarda le dirette 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 features_aria: Vantaggi principali
feature_stable: Dirette stabili feature_stable: Dirette stabili
feature_archive: Archivio sicuro feature_archive: Archivio sicuro
+5
View File
@@ -93,11 +93,15 @@ Rails.application.routes.draw do
end end
get "billing", to: "billing#index", as: :billing get "billing", to: "billing#index", as: :billing
post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf post "billing/payments/:payment_id/attach_pdf", to: "billing#attach_pdf", as: :billing_payment_attach_pdf
post "billing/transfers/:id/confirm", to: "billing#confirm_transfer", as: :billing_transfer_confirm
post "billing/transfers/:id/cancel", to: "billing#cancel_transfer", as: :billing_transfer_cancel
resources :teams, only: %i[show] resources :teams, only: %i[show]
resources :clubs, only: %i[index show] do resources :clubs, only: %i[index show] do
member do member do
post :grant_comped post :grant_comped
delete :revoke_comped delete :revoke_comped
post :set_quote
delete :revoke_quote
end end
resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices" resources :billing_invoices, only: %i[index new create edit update], controller: "billing_invoices"
resources :club_recordings, only: %i[index update destroy], path: "replays", as: :recordings do resources :club_recordings, only: %i[index update destroy], path: "replays", as: :recordings do
@@ -198,6 +202,7 @@ Rails.application.routes.draw do
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
patch "clubs/:id/billing/profile", to: "club_billing#update_profile" patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription post "clubs/:id/billing/cancel_subscription", to: "club_billing#cancel_subscription", as: :club_billing_cancel_subscription
post "clubs/:id/billing/bank_transfer", to: "club_billing#request_bank_transfer", as: :club_billing_bank_transfer
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
+2
View File
@@ -1,5 +1,7 @@
:concurrency: 5 :concurrency: 5
:queues: :queues:
- critical - critical
- youtube_relay_home
- youtube_relay_cloud
- youtube_relay - youtube_relay
- default - default
@@ -0,0 +1,42 @@
class AddBankTransferBilling < ActiveRecord::Migration[7.2]
def change
create_table :billing_club_quotes, id: :uuid do |t|
t.references :club, null: false, foreign_key: true, type: :uuid, index: false
t.string :plan_slug, null: false
t.string :billing_interval, null: false
t.integer :amount_cents, null: false
t.string :currency, null: false, default: "eur"
t.string :note
t.boolean :active, null: false, default: true
t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
t.timestamps
end
add_index :billing_club_quotes, :club_id, unique: true, where: "active = TRUE",
name: "index_billing_club_quotes_active_club"
create_table :billing_transfer_orders, id: :uuid do |t|
t.references :club, null: false, foreign_key: true, type: :uuid
t.references :billing_club_quote, foreign_key: true, type: :uuid
t.references :billing_payment, foreign_key: true, type: :uuid
t.string :plan_slug, null: false
t.string :billing_interval, null: false
t.integer :amount_cents, null: false
t.string :currency, null: false, default: "eur"
t.string :kind, null: false
t.string :status, null: false, default: "awaiting_payment"
t.string :reference_code, null: false
t.references :requested_by_user, foreign_key: { to_table: :users }, type: :uuid
t.references :confirmed_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
t.datetime :confirmed_at
t.datetime :cancelled_at
t.timestamps
end
add_index :billing_transfer_orders, :reference_code, unique: true
add_index :billing_transfer_orders, :status
add_index :billing_transfer_orders, :club_id, unique: true, where: "status = 'awaiting_payment'",
name: "index_billing_transfer_orders_awaiting_club"
add_column :billing_payments, :provider, :string, null: false, default: "stripe"
add_index :billing_payments, :provider
end
end
+53 -2
View File
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" enable_extension "plpgsql"
@@ -51,6 +51,21 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.index ["username"], name: "index_admin_accounts_on_username", unique: true t.index ["username"], name: "index_admin_accounts_on_username", unique: true
end end
create_table "billing_club_quotes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.string "plan_slug", null: false
t.string "billing_interval", null: false
t.integer "amount_cents", null: false
t.string "currency", default: "eur", null: false
t.string "note"
t.boolean "active", default: true, null: false
t.uuid "created_by_admin_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["club_id"], name: "index_billing_club_quotes_active_club", unique: true, where: "(active = true)"
t.index ["created_by_admin_id"], name: "index_billing_club_quotes_on_created_by_admin_id"
end
create_table "billing_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "billing_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false t.uuid "club_id", null: false
t.uuid "billing_payment_id" t.uuid "billing_payment_id"
@@ -87,11 +102,40 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.string "receipt_url" t.string "receipt_url"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "provider", default: "stripe", null: false
t.index ["club_id", "paid_at"], name: "index_billing_payments_on_club_id_and_paid_at" t.index ["club_id", "paid_at"], name: "index_billing_payments_on_club_id_and_paid_at"
t.index ["club_id"], name: "index_billing_payments_on_club_id" t.index ["club_id"], name: "index_billing_payments_on_club_id"
t.index ["provider"], name: "index_billing_payments_on_provider"
t.index ["stripe_invoice_id"], name: "index_billing_payments_on_stripe_invoice_id", unique: true, where: "(stripe_invoice_id IS NOT NULL)" t.index ["stripe_invoice_id"], name: "index_billing_payments_on_stripe_invoice_id", unique: true, where: "(stripe_invoice_id IS NOT NULL)"
end end
create_table "billing_transfer_orders", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.uuid "billing_club_quote_id"
t.uuid "billing_payment_id"
t.string "plan_slug", null: false
t.string "billing_interval", null: false
t.integer "amount_cents", null: false
t.string "currency", default: "eur", null: false
t.string "kind", null: false
t.string "status", default: "awaiting_payment", null: false
t.string "reference_code", null: false
t.uuid "requested_by_user_id"
t.uuid "confirmed_by_admin_id"
t.datetime "confirmed_at"
t.datetime "cancelled_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["billing_club_quote_id"], name: "index_billing_transfer_orders_on_billing_club_quote_id"
t.index ["billing_payment_id"], name: "index_billing_transfer_orders_on_billing_payment_id"
t.index ["club_id"], name: "index_billing_transfer_orders_awaiting_club", unique: true, where: "((status)::text = 'awaiting_payment'::text)"
t.index ["club_id"], name: "index_billing_transfer_orders_on_club_id"
t.index ["confirmed_by_admin_id"], name: "index_billing_transfer_orders_on_confirmed_by_admin_id"
t.index ["reference_code"], name: "index_billing_transfer_orders_on_reference_code", unique: true
t.index ["requested_by_user_id"], name: "index_billing_transfer_orders_on_requested_by_user_id"
t.index ["status"], name: "index_billing_transfer_orders_on_status"
end
create_table "club_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "club_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "user_id", null: false t.uuid "user_id", null: false
t.uuid "club_id", null: false t.uuid "club_id", null: false
@@ -305,8 +349,8 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
t.string "regia_token_digest" t.string "regia_token_digest"
t.datetime "regia_token_expires_at" t.datetime "regia_token_expires_at"
t.uuid "stream_node_id" t.uuid "stream_node_id"
t.boolean "audio_muted", default: false, null: false
t.string "min_quality_preset", default: "auto", null: false t.string "min_quality_preset", default: "auto", null: false
t.boolean "audio_muted", default: false, null: false
t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["match_id"], name: "index_stream_sessions_on_match_id"
t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true
t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true
@@ -427,9 +471,16 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_13_184100) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "billing_club_quotes", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "clubs"
add_foreign_key "billing_invoices", "billing_payments" add_foreign_key "billing_invoices", "billing_payments"
add_foreign_key "billing_invoices", "clubs" add_foreign_key "billing_invoices", "clubs"
add_foreign_key "billing_payments", "clubs" add_foreign_key "billing_payments", "clubs"
add_foreign_key "billing_transfer_orders", "admin_accounts", column: "confirmed_by_admin_id"
add_foreign_key "billing_transfer_orders", "billing_club_quotes"
add_foreign_key "billing_transfer_orders", "billing_payments"
add_foreign_key "billing_transfer_orders", "clubs"
add_foreign_key "billing_transfer_orders", "users", column: "requested_by_user_id"
add_foreign_key "club_memberships", "clubs" add_foreign_key "club_memberships", "clubs"
add_foreign_key "club_memberships", "users" add_foreign_key "club_memberships", "users"
add_foreign_key "device_states", "stream_sessions" add_foreign_key "device_states", "stream_sessions"
+122
View File
@@ -29,4 +29,126 @@ namespace :streams do
puts Streams::DnsProviders::Lab.new.hosts_file_snippet puts Streams::DnsProviders::Lab.new.hosts_file_snippet
end end
end end
namespace :relay do
desc "Mostra coda e slug nodo per i worker ffmpeg YouTube"
task queues: :environment do
puts "local_slug=#{Streams::YoutubeRelay.local_node_slug} worker=#{Streams::YoutubeRelay.worker?}"
StreamNode.order(:slug).find_each do |node|
puts "node=#{node.slug} queue=youtube_relay_#{node.slug} status=#{node.status} relays=#{node.max_relays}"
end
end
desc "Simula un telefono RTMP e verifica che ffmpeg parta sul worker del nodo lab"
task simulate: :environment do
abort "Esegui questo task nel container Rails (docker compose exec rails ...)" unless File.exist?("/.dockerenv") || ENV["FORCE_RELAY_SIMULATE"] == "1"
home = Streams::NodeRegistry.ensure_home_from_env!
lab = StreamNode.find_by(slug: ENV.fetch("STREAM_NODE_SLUG", "ingest-lab-01"))
lab ||= Streams::NodeProvisioner.new.provision_lab!
user = User.find_by!(email: ENV.fetch("E2E_USER_EMAIL", "coach@matchlivetv.test"))
team = user.teams.first || user.clubs.first&.teams&.first || Team.first
abort "Nessun team per #{user.email}" unless team
match = team.matches.first || team.matches.create!(opponent_name: "Relay sim")
previous_max = home.max_publishers
filler = nil
session = nil
ffmpeg_pid = nil
begin
home.update!(max_publishers: 1, status: "ready")
filler = occupy_home!(home, user, match)
session = build_lab_youtube_session!(lab, user, match)
Mediamtx::Client.for_session(session).create_path(session)
session.begin_connect! if session.may_begin_connect?
session.update!(status: "connecting") unless session.status.in?(%w[connecting live reconnecting])
puts "filler_home=#{filler.id} lab_session=#{session.id} node=#{session.stream_node.slug} queue=#{Streams::YoutubeRelay.queue_for(session)}"
ffmpeg_pid = spawn_test_rtmp!(session)
wait_for!("publisher MediaMTX", 30) { Mediamtx::PublisherOnline.active?(session.reload) }
Streams::YoutubeRelay.enqueue_ensure!(session)
wait_for!("owner Redis sul nodo lab", 25) do
owner_for(session.id) == lab.slug
end
owner = owner_for(session.id)
home_owns = relay_redis.sismember(format(Streams::YoutubeRelay::OWNED_SET, "home"), session.id)
puts "owner=#{owner} home_owns=#{home_owns} queue=#{Streams::YoutubeRelay.queue_for(session)}"
abort "FAIL: owner atteso #{lab.slug}, trovato #{owner.inspect}" unless owner == lab.slug
abort "FAIL: il worker home ha reclamato la sessione lab" if home_owns
puts "OK — ffmpeg assegnato al worker #{lab.slug}, non a home"
ensure
begin
Process.kill("TERM", ffmpeg_pid) if ffmpeg_pid
rescue Errno::ESRCH
nil
end
session&.update!(status: "ended", ended_at: Time.current) if session&.persisted?
filler&.update!(status: "ended", ended_at: Time.current) if filler&.persisted?
home.update!(max_publishers: previous_max)
end
end
end
end
def occupy_home!(home, user, match)
StreamSession.create!(
match: match,
user: user,
platform: "matchlivetv",
status: "live",
privacy_status: "unlisted",
stream_node: home,
publish_token: SecureRandom.hex(8),
started_at: Time.current
)
end
def build_lab_youtube_session!(lab, user, match)
StreamSession.create!(
match: match,
user: user,
platform: "youtube",
status: "idle",
privacy_status: "unlisted",
stream_node: lab,
publish_token: SecureRandom.hex(8),
stream_key: "e2e-dummy-#{SecureRandom.hex(6)}"
)
end
def spawn_test_rtmp!(session)
intake = "#{session.mediamtx_internal_rtmp_url.chomp('/')}/#{session.mediamtx_path_name}"
cmd = [
"ffmpeg", "-hide_banner", "-loglevel", "error",
"-re", "-f", "lavfi", "-i", "testsrc=size=1280x720:rate=30",
"-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000",
"-t", "40",
"-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-b:v", "1500k",
"-c:a", "aac", "-ac", "1", "-ar", "48000", "-b:a", "96k",
"-f", "flv", intake
]
pid = Process.spawn(*cmd, %i[out err] => "/tmp/relay_sim_ffmpeg.log")
Process.detach(pid)
puts "telefono simulato pid=#{pid} rtmp=#{intake}"
pid
end
def wait_for!(label, timeout_sec)
deadline = Time.now + timeout_sec
loop do
return true if yield
abort "FAIL timeout: #{label}" if Time.now >= deadline
sleep 1
end
end
def owner_for(session_id)
relay_redis.get(format(Streams::YoutubeRelay::OWNER_KEY, session_id))
end
def relay_redis
@relay_redis ||= Redis.new(url: ENV.fetch("REDIS_URL", "redis://redis:6379/0"))
end end
+3
View File
@@ -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

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