Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d52434fb2e | ||
|
|
356aa28fad | ||
|
|
1185d0ce61 | ||
|
|
90d481a2d1 | ||
|
|
50b8a8c8e2 | ||
|
|
79850dfc2c | ||
|
|
645807b853 | ||
|
|
b08049cf69 | ||
|
|
29c8afb94d | ||
|
|
e0e07e5316 | ||
|
|
42dadb993d |
@@ -8,6 +8,7 @@ module Admin
|
|||||||
to: parse_date(params[:to]) || Time.zone.today,
|
to: parse_date(params[:to]) || Time.zone.today,
|
||||||
device: params[:device].presence
|
device: params[:device].presence
|
||||||
}
|
}
|
||||||
|
@analytics_preview_active = analytics_preview_active?
|
||||||
|
|
||||||
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
|
scope = AnalyticsPageStat.where(day: @filters[:from]..@filters[:to])
|
||||||
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
scope = scope.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
||||||
@@ -41,15 +42,23 @@ module Admin
|
|||||||
@page_path = params[:page_path].to_s
|
@page_path = params[:page_path].to_s
|
||||||
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
|
redirect_to admin_analytics_path, alert: t("admin.analytics.missing_path") and return if @page_path.blank?
|
||||||
|
|
||||||
|
from = parse_date(params[:from]) || 7.days.ago.to_date
|
||||||
|
to = parse_date(params[:to]) || Time.zone.today
|
||||||
|
@device_tab_stats = device_tab_stats(@page_path, from, to)
|
||||||
|
device = resolve_heatmap_device(@device_tab_stats, params[:device].presence)
|
||||||
|
|
||||||
@filters = {
|
@filters = {
|
||||||
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
from: from,
|
||||||
to: parse_date(params[:to]) || Time.zone.today,
|
to: to,
|
||||||
device: params[:device].presence,
|
device: device,
|
||||||
layer: params[:layer].to_s
|
layer: params[:layer].to_s
|
||||||
}
|
}
|
||||||
|
|
||||||
cells = AnalyticsPageCell.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
cells = AnalyticsPageCell.where(
|
||||||
cells = cells.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
page_path: @page_path,
|
||||||
|
day: @filters[:from]..@filters[:to],
|
||||||
|
device: @filters[:device]
|
||||||
|
)
|
||||||
|
|
||||||
@click_total = cells.sum(:click_count)
|
@click_total = cells.sum(:click_count)
|
||||||
@move_total = cells.sum(:move_count)
|
@move_total = cells.sum(:move_count)
|
||||||
@@ -67,8 +76,11 @@ module Admin
|
|||||||
@max_weight = @cells.values.max.to_i
|
@max_weight = @cells.values.max.to_i
|
||||||
@total_points = @cells.values.sum
|
@total_points = @cells.values.sum
|
||||||
|
|
||||||
stats = AnalyticsPageStat.where(page_path: @page_path, day: @filters[:from]..@filters[:to])
|
stats = AnalyticsPageStat.where(
|
||||||
stats = stats.where(device: @filters[:device]) if @filters[:device].present? && AnalyticsEvent::DEVICES.include?(@filters[:device])
|
page_path: @page_path,
|
||||||
|
day: @filters[:from]..@filters[:to],
|
||||||
|
device: @filters[:device]
|
||||||
|
)
|
||||||
@pageviews = stats.sum(:pageview_count)
|
@pageviews = stats.sum(:pageview_count)
|
||||||
@scroll_samples = stats.sum(:scroll_samples)
|
@scroll_samples = stats.sum(:scroll_samples)
|
||||||
@scroll_sum = stats.sum(:scroll_sum_pct)
|
@scroll_sum = stats.sum(:scroll_sum_pct)
|
||||||
@@ -78,8 +90,30 @@ module Admin
|
|||||||
@snapshot = find_snapshot(@page_path, @filters[:device])
|
@snapshot = find_snapshot(@page_path, @filters[:device])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def preview_enable
|
||||||
|
Analytics::Suppress.enable!(cookies)
|
||||||
|
redirect_to preview_return_to(params[:return_to]), notice: t("admin.analytics.preview.enabled")
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_disable
|
||||||
|
Analytics::Suppress.disable!(cookies)
|
||||||
|
redirect_to admin_analytics_path, notice: t("admin.analytics.preview.disabled")
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def analytics_preview_active?
|
||||||
|
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
|
||||||
|
end
|
||||||
|
|
||||||
|
def preview_return_to(value)
|
||||||
|
path = value.to_s.strip
|
||||||
|
return root_path if path.blank?
|
||||||
|
return path if path.start_with?("/") && !path.start_with?("//")
|
||||||
|
|
||||||
|
admin_analytics_path
|
||||||
|
end
|
||||||
|
|
||||||
def parse_date(value)
|
def parse_date(value)
|
||||||
return nil if value.blank?
|
return nil if value.blank?
|
||||||
|
|
||||||
@@ -88,14 +122,43 @@ module Admin
|
|||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_snapshot(page_path, device)
|
def device_tab_stats(page_path, from, to)
|
||||||
scope = AnalyticsPageSnapshot.where(page_path: page_path)
|
cell_totals = AnalyticsPageCell.where(page_path: page_path, day: from..to)
|
||||||
if device.present? && AnalyticsEvent::DEVICES.include?(device)
|
.group(:device)
|
||||||
snap = scope.find_by(device: device)
|
.pluck(
|
||||||
return snap if snap&.image&.attached?
|
:device,
|
||||||
|
Arel.sql("SUM(click_count)"),
|
||||||
|
Arel.sql("SUM(move_count)")
|
||||||
|
)
|
||||||
|
cell_by_device = cell_totals.to_h { |device, clicks, moves| [device, { clicks: clicks.to_i, moves: moves.to_i }] }
|
||||||
|
|
||||||
|
pageview_totals = AnalyticsPageStat.where(page_path: page_path, day: from..to)
|
||||||
|
.group(:device)
|
||||||
|
.sum(:pageview_count)
|
||||||
|
|
||||||
|
AnalyticsEvent::DEVICES.index_with do |device|
|
||||||
|
cells = cell_by_device[device] || { clicks: 0, moves: 0 }
|
||||||
|
cells.merge(pageviews: pageview_totals[device].to_i)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def resolve_heatmap_device(tab_stats, requested)
|
||||||
|
if requested.present? && AnalyticsEvent::DEVICES.include?(requested)
|
||||||
|
return requested
|
||||||
end
|
end
|
||||||
|
|
||||||
scope.order(captured_at: :desc).detect { |s| s.image.attached? }
|
AnalyticsEvent::DEVICES.max_by do |device|
|
||||||
|
stats = tab_stats[device]
|
||||||
|
stats[:clicks] + stats[:moves] + stats[:pageviews]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_snapshot(page_path, device)
|
||||||
|
return nil unless AnalyticsEvent::DEVICES.include?(device)
|
||||||
|
|
||||||
|
AnalyticsPageSnapshot.where(page_path: page_path, device: device)
|
||||||
|
.order(captured_at: :desc)
|
||||||
|
.detect { |snapshot| snapshot.image.attached? }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ module Admin
|
|||||||
@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
|
@quote = @club.active_billing_quote
|
||||||
|
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
def grant_comped
|
def grant_comped
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class CostEntriesController < Admin::BaseController
|
||||||
|
before_action :set_entry, only: %i[edit update destroy]
|
||||||
|
|
||||||
|
def new
|
||||||
|
@entry = PlatformCostEntry.new(
|
||||||
|
month: parse_month(params[:month]) || Time.zone.today.beginning_of_month,
|
||||||
|
label: PlatformCostEntry::DEFAULT_LABEL
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create
|
||||||
|
@entry = PlatformCostEntry.new(entry_attributes)
|
||||||
|
if @entry.save
|
||||||
|
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_created")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = @entry.errors.full_messages.join(", ")
|
||||||
|
render :new, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def edit; end
|
||||||
|
|
||||||
|
def update
|
||||||
|
if @entry.update(entry_attributes)
|
||||||
|
redirect_to admin_costs_path(month: month_param(@entry.month)), notice: t("admin.flash.cost_entry_updated")
|
||||||
|
else
|
||||||
|
flash.now[:alert] = @entry.errors.full_messages.join(", ")
|
||||||
|
render :edit, status: :unprocessable_entity
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def destroy
|
||||||
|
month = @entry.month
|
||||||
|
@entry.destroy!
|
||||||
|
redirect_to admin_costs_path(month: month_param(month)), notice: t("admin.flash.cost_entry_destroyed")
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def set_entry
|
||||||
|
@entry = PlatformCostEntry.find(params[:id])
|
||||||
|
end
|
||||||
|
|
||||||
|
def entry_attributes
|
||||||
|
attrs = params.require(:platform_cost_entry).permit(:month, :label, :amount_euros, :notes)
|
||||||
|
if attrs[:amount_euros].present?
|
||||||
|
attrs[:amount_cents] = Billing::EuroAmount.to_cents(attrs.delete(:amount_euros))
|
||||||
|
end
|
||||||
|
attrs[:notes] = nil if attrs[:notes].blank?
|
||||||
|
attrs
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_month(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def month_param(date)
|
||||||
|
date.strftime("%Y-%m")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class CostsController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
@month = parse_month(params[:month]) || Time.zone.today.beginning_of_month
|
||||||
|
analytics = Admin::CostAnalytics.new(month: @month)
|
||||||
|
@summary = analytics.summary
|
||||||
|
@trend = analytics.trend
|
||||||
|
@clubs = analytics.club_breakdown
|
||||||
|
@entries = PlatformCostEntry.for_month(@month).ordered
|
||||||
|
@month_options = month_options(@month)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def parse_month(value)
|
||||||
|
return nil if value.blank?
|
||||||
|
|
||||||
|
Date.strptime(value.to_s, "%Y-%m").beginning_of_month
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def month_options(selected)
|
||||||
|
start = selected - 23.months
|
||||||
|
(0..23).map { |i| start + i.months }.reverse
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -8,6 +8,8 @@ module Admin
|
|||||||
.includes(:stream_node, match: :team)
|
.includes(:stream_node, match: :team)
|
||||||
.order(started_at: :desc)
|
.order(started_at: :desc)
|
||||||
@teams = Team.includes(:matches).order(:name).limit(8)
|
@teams = Team.includes(:matches).order(:name).limit(8)
|
||||||
|
@recent_concurrency_violations = StreamConcurrencyViolation.recent.limit(8)
|
||||||
|
@concurrency_violation_lookback = StreamConcurrencyViolation.lookback.count
|
||||||
end
|
end
|
||||||
|
|
||||||
def metrics
|
def metrics
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ module Admin
|
|||||||
.find(params[:id])
|
.find(params[:id])
|
||||||
@events = @session.stream_events.recent.limit(100)
|
@events = @session.stream_events.recent.limit(100)
|
||||||
@club = @session.match.team.club
|
@club = @session.match.team.club
|
||||||
|
@concurrency_violations = StreamConcurrencyViolation.for_session(@session.id).recent.limit(20)
|
||||||
end
|
end
|
||||||
|
|
||||||
def stop
|
def stop
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class StreamConcurrencyViolationsController < Admin::BaseController
|
||||||
|
def index
|
||||||
|
@filters = {
|
||||||
|
q: params[:q].to_s.strip.presence,
|
||||||
|
devices_differ: params[:devices_differ].to_s == "1"
|
||||||
|
}
|
||||||
|
scope = StreamConcurrencyViolation.recent
|
||||||
|
if @filters[:q]
|
||||||
|
term = "%#{ActiveRecord::Base.sanitize_sql_like(@filters[:q])}%"
|
||||||
|
scope = scope.where(
|
||||||
|
"user_email ILIKE :term OR user_name ILIKE :term OR occupying_club_name ILIKE :term OR attempted_club_name ILIKE :term OR occupying_match_label ILIKE :term OR attempted_match_label ILIKE :term",
|
||||||
|
term: term
|
||||||
|
)
|
||||||
|
end
|
||||||
|
scope = scope.two_devices if @filters[:devices_differ]
|
||||||
|
@total_count = scope.count
|
||||||
|
@violations = scope.limit(200)
|
||||||
|
@lookback_count = StreamConcurrencyViolation.lookback.count
|
||||||
|
@two_devices_count = StreamConcurrencyViolation.lookback.two_devices.count
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
module Analytics
|
module Analytics
|
||||||
class EventsController < ActionController::API
|
class EventsController < ActionController::API
|
||||||
|
include ActionController::Cookies
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
if analytics_suppressed?
|
||||||
|
return render json: { accepted: 0, rejected: 0, suppressed: true }, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
payload = parse_payload
|
payload = parse_payload
|
||||||
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
|
result = Analytics::Ingest.new(events: payload, remote_ip: request.remote_ip).call
|
||||||
|
|
||||||
@@ -15,6 +21,10 @@ module Analytics
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def analytics_suppressed?
|
||||||
|
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
|
||||||
|
end
|
||||||
|
|
||||||
def parse_payload
|
def parse_payload
|
||||||
body = request.request_parameters
|
body = request.request_parameters
|
||||||
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
|
return body["events"] if body.is_a?(Hash) && body["events"].is_a?(Array)
|
||||||
|
|||||||
@@ -2,7 +2,13 @@
|
|||||||
|
|
||||||
module Analytics
|
module Analytics
|
||||||
class SnapshotsController < ActionController::API
|
class SnapshotsController < ActionController::API
|
||||||
|
include ActionController::Cookies
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
if analytics_suppressed?
|
||||||
|
return render json: { ok: true, skipped: true, suppressed: true }, status: :accepted
|
||||||
|
end
|
||||||
|
|
||||||
result = Analytics::SnapshotIngest.new(
|
result = Analytics::SnapshotIngest.new(
|
||||||
path: params[:path] || params[:page_path],
|
path: params[:path] || params[:page_path],
|
||||||
device: params[:device],
|
device: params[:device],
|
||||||
@@ -17,5 +23,11 @@ module Analytics
|
|||||||
|
|
||||||
render json: { ok: true, skipped: result.skipped }, status: :accepted
|
render json: { ok: true, skipped: result.skipped }, status: :accepted
|
||||||
end
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def analytics_suppressed?
|
||||||
|
Analytics::Suppress.active?(cookies[Analytics::Suppress::COOKIE_NAME])
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,21 @@
|
|||||||
module AdminHelper
|
module AdminHelper
|
||||||
|
def format_euros(cents, precision: 2)
|
||||||
|
return I18n.t("admin.common.dash") if cents.nil?
|
||||||
|
|
||||||
|
format("%.*f €", precision, cents.to_f / 100.0)
|
||||||
|
end
|
||||||
|
|
||||||
|
def format_hours(hours)
|
||||||
|
return I18n.t("admin.common.dash") if hours.nil? || hours.to_f <= 0
|
||||||
|
|
||||||
|
total_minutes = (hours.to_f * 60).round
|
||||||
|
format_duration_minutes(total_minutes)
|
||||||
|
end
|
||||||
|
|
||||||
|
def admin_month_label(month)
|
||||||
|
I18n.l(month, format: "%B %Y")
|
||||||
|
end
|
||||||
|
|
||||||
def format_bytes(bytes)
|
def format_bytes(bytes)
|
||||||
return "—" if bytes.nil?
|
return "—" if bytes.nil?
|
||||||
|
|
||||||
@@ -171,4 +188,8 @@ module AdminHelper
|
|||||||
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
||||||
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def admin_concurrency_violation_lookback_count
|
||||||
|
@admin_concurrency_violation_lookback_count ||= StreamConcurrencyViolation.lookback.count
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ module LegalHelper
|
|||||||
"20 agosto 2026"
|
"20 agosto 2026"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def terms_last_updated
|
||||||
|
"31 agosto 2026"
|
||||||
|
end
|
||||||
|
|
||||||
def cookie_policy_last_updated
|
def cookie_policy_last_updated
|
||||||
"3 giugno 2026"
|
"3 giugno 2026"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -15,7 +15,16 @@ module Recordings
|
|||||||
result = Recordings::VerifyYoutubeReplay.new(recording).call
|
result = Recordings::VerifyYoutubeReplay.new(recording).call
|
||||||
|
|
||||||
if result.ok?
|
if result.ok?
|
||||||
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
|
recording.reload
|
||||||
|
embeddable = recording.metadata.is_a?(Hash) && recording.metadata.dig("youtube", "embeddable") != false
|
||||||
|
if embeddable
|
||||||
|
Recordings::ClearTemporaryMediaJob.perform_async(recording.id, "verified")
|
||||||
|
else
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Recordings::VerifyYoutubeReplayJob] keep_temp recording=#{recording.id} " \
|
||||||
|
"youtube_not_embeddable"
|
||||||
|
)
|
||||||
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class PlatformCostEntry < ApplicationRecord
|
||||||
|
DEFAULT_LABEL = "Piattaforma produzione"
|
||||||
|
|
||||||
|
validates :month, presence: true
|
||||||
|
validates :label, presence: true, length: { maximum: 120 }
|
||||||
|
validates :amount_cents, numericality: { only_integer: true, greater_than: 0 }
|
||||||
|
|
||||||
|
before_validation :normalize_month
|
||||||
|
|
||||||
|
scope :for_month, ->(date) { where(month: date.to_date.beginning_of_month) }
|
||||||
|
scope :ordered, -> { order(month: :desc, created_at: :desc) }
|
||||||
|
|
||||||
|
def month=(value)
|
||||||
|
if value.is_a?(String) && value.match?(/\A\d{4}-\d{2}\z/)
|
||||||
|
super(Date.strptime(value, "%Y-%m"))
|
||||||
|
else
|
||||||
|
super(value)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def amount_euros
|
||||||
|
amount_cents.to_f / 100.0
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def normalize_month
|
||||||
|
return if month.blank?
|
||||||
|
|
||||||
|
parsed =
|
||||||
|
case month
|
||||||
|
when Date
|
||||||
|
month
|
||||||
|
when Time, ActiveSupport::TimeWithZone
|
||||||
|
month.to_date
|
||||||
|
when String
|
||||||
|
if month.match?(/\A\d{4}-\d{2}\z/)
|
||||||
|
Date.strptime(month, "%Y-%m")
|
||||||
|
else
|
||||||
|
Date.parse(month)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
Date.parse(month.to_s)
|
||||||
|
end
|
||||||
|
self.month = parsed.beginning_of_month
|
||||||
|
rescue ArgumentError, TypeError
|
||||||
|
errors.add(:month, :invalid)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class StreamConcurrencyViolation < ApplicationRecord
|
||||||
|
LOOKBACK = 30.days
|
||||||
|
|
||||||
|
belongs_to :user, optional: true
|
||||||
|
belongs_to :occupying_session, class_name: "StreamSession", optional: true
|
||||||
|
belongs_to :attempted_session, class_name: "StreamSession", optional: true
|
||||||
|
belongs_to :occupying_club, class_name: "Club", optional: true
|
||||||
|
belongs_to :attempted_club, class_name: "Club", optional: true
|
||||||
|
|
||||||
|
validates :user_email, :occupying_match_label, :attempted_match_label, presence: true
|
||||||
|
validates :attempt_action, inclusion: { in: %w[start resume] }
|
||||||
|
|
||||||
|
scope :recent, -> { order(created_at: :desc) }
|
||||||
|
scope :since, ->(time) { where("created_at >= ?", time) }
|
||||||
|
scope :lookback, -> { since(LOOKBACK.ago) }
|
||||||
|
scope :two_devices, -> { where(devices_differ: true) }
|
||||||
|
scope :for_club, lambda { |club_id|
|
||||||
|
where("occupying_club_id = :id OR attempted_club_id = :id", id: club_id)
|
||||||
|
}
|
||||||
|
scope :for_session, lambda { |session_id|
|
||||||
|
where("occupying_session_id = :id OR attempted_session_id = :id", id: session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
def self.record!(attempted:, occupying:, action: "start")
|
||||||
|
user = attempted.user || occupying.user
|
||||||
|
occupying_club = occupying.match&.team&.club
|
||||||
|
attempted_club = attempted.match&.team&.club
|
||||||
|
occupying_device = occupying.client_device_label
|
||||||
|
attempted_device = attempted.client_device_label
|
||||||
|
|
||||||
|
create!(
|
||||||
|
user: user,
|
||||||
|
occupying_session: occupying,
|
||||||
|
attempted_session: attempted,
|
||||||
|
occupying_club: occupying_club,
|
||||||
|
attempted_club: attempted_club,
|
||||||
|
attempt_action: action.to_s,
|
||||||
|
user_email: user&.email.presence || "unknown",
|
||||||
|
user_name: user&.name,
|
||||||
|
occupying_club_name: occupying_club&.name,
|
||||||
|
attempted_club_name: attempted_club&.name,
|
||||||
|
occupying_match_label: occupying.match_label,
|
||||||
|
attempted_match_label: attempted.match_label,
|
||||||
|
occupying_status: occupying.status,
|
||||||
|
occupying_device: occupying_device,
|
||||||
|
attempted_device: attempted_device,
|
||||||
|
devices_differ: StreamSession.devices_differ?(occupying, attempted),
|
||||||
|
metadata: {
|
||||||
|
occupying_session_id: occupying.id,
|
||||||
|
attempted_session_id: attempted.id,
|
||||||
|
occupying_client_os: occupying.client_os,
|
||||||
|
attempted_client_os: attempted.client_os,
|
||||||
|
occupying_app_version: occupying.app_version,
|
||||||
|
attempted_app_version: attempted.app_version
|
||||||
|
}.compact
|
||||||
|
)
|
||||||
|
rescue StandardError => e
|
||||||
|
Rails.logger.error("[StreamConcurrencyViolation] record failed: #{e.class} #{e.message}")
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_name
|
||||||
|
attempted_club_name.presence || occupying_club_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def operator_label
|
||||||
|
[user_name.presence, user_email].compact.join(" · ")
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,6 +10,10 @@ class StreamSession < ApplicationRecord
|
|||||||
belongs_to :user
|
belongs_to :user
|
||||||
belongs_to :stream_node, optional: true
|
belongs_to :stream_node, optional: true
|
||||||
has_many :stream_events, dependent: :destroy
|
has_many :stream_events, dependent: :destroy
|
||||||
|
has_many :occupying_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
||||||
|
foreign_key: :occupying_session_id, dependent: :nullify, inverse_of: :occupying_session
|
||||||
|
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
||||||
|
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
|
||||||
has_one :score_state, dependent: :destroy
|
has_one :score_state, dependent: :destroy
|
||||||
has_one :recording
|
has_one :recording
|
||||||
has_many :device_states, dependent: :destroy
|
has_many :device_states, dependent: :destroy
|
||||||
@@ -84,6 +88,35 @@ class StreamSession < ApplicationRecord
|
|||||||
stream_node&.role.presence || ingest_role
|
stream_node&.role.presence || ingest_role
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def match_label
|
||||||
|
team_name = match&.team&.name
|
||||||
|
opponent = match&.opponent_name
|
||||||
|
return id.to_s if team_name.blank?
|
||||||
|
|
||||||
|
opponent.present? ? "#{team_name} vs #{opponent}" : team_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def client_device_label
|
||||||
|
parts = []
|
||||||
|
parts << client_os if client_os.present?
|
||||||
|
device = [device_manufacturer, device_model].compact_blank.join(" ")
|
||||||
|
parts << device if device.present?
|
||||||
|
parts << "OS #{os_version}" if os_version.present?
|
||||||
|
parts.join(" · ").presence
|
||||||
|
end
|
||||||
|
|
||||||
|
def client_device_key
|
||||||
|
[client_os, device_manufacturer, device_model].map { |v| v.to_s.strip.downcase }.join("|")
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.devices_differ?(left, right)
|
||||||
|
ka = left.client_device_key
|
||||||
|
kb = right.client_device_key
|
||||||
|
return false if ka.delete("|").blank? || kb.delete("|").blank?
|
||||||
|
|
||||||
|
ka != kb
|
||||||
|
end
|
||||||
|
|
||||||
def rtmp_ingest_url
|
def rtmp_ingest_url
|
||||||
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
# RootEncoder richiede rtmp://host:port/app/stream (due segmenti).
|
||||||
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
# MediaMTX path = live/match_{uuid} (no ?token= nel path).
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class User < ApplicationRecord
|
|||||||
has_many :clubs, through: :club_memberships
|
has_many :clubs, through: :club_memberships
|
||||||
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
|
has_many :owned_clubs, -> { where(club_memberships: { role: "owner" }) }, through: :club_memberships, source: :club
|
||||||
has_many :stream_sessions, dependent: :nullify
|
has_many :stream_sessions, dependent: :nullify
|
||||||
|
has_many :stream_concurrency_violations, dependent: :nullify
|
||||||
|
|
||||||
def manageable_teams
|
def manageable_teams
|
||||||
staff_ids = teams.select(:id)
|
staff_ids = teams.select(:id)
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Admin
|
||||||
|
class CostAnalytics
|
||||||
|
TREND_MONTHS = 12
|
||||||
|
|
||||||
|
def initialize(month:)
|
||||||
|
@month = month.to_date.beginning_of_month
|
||||||
|
end
|
||||||
|
|
||||||
|
def summary
|
||||||
|
build_period(@month)
|
||||||
|
end
|
||||||
|
|
||||||
|
def trend(months: TREND_MONTHS)
|
||||||
|
start_month = @month - (months - 1).months
|
||||||
|
months_list = (0...months).map { |i| start_month + i.months }
|
||||||
|
months_list.map { |m| build_period(m) }
|
||||||
|
end
|
||||||
|
|
||||||
|
def club_breakdown
|
||||||
|
range = month_range(@month)
|
||||||
|
rows = session_rows(range)
|
||||||
|
total_secs = rows.sum { |row| row.total_secs.to_i }
|
||||||
|
total_cost_cents = PlatformCostEntry.for_month(@month).sum(:amount_cents)
|
||||||
|
revenue_by_club = revenue_by_club(range)
|
||||||
|
storage_by_club = storage_by_club_index
|
||||||
|
club_ids = rows.map(&:club_id)
|
||||||
|
clubs = Club.where(id: club_ids).includes(subscription: :plan).index_by(&:id)
|
||||||
|
|
||||||
|
rows.map do |row|
|
||||||
|
club = clubs[row.club_id]
|
||||||
|
secs = row.total_secs.to_i
|
||||||
|
hours = secs / 3600.0
|
||||||
|
sessions_count = row.sessions_count.to_i
|
||||||
|
share = total_secs.positive? ? secs.to_f / total_secs : 0.0
|
||||||
|
allocated_cents = (total_cost_cents * share).round
|
||||||
|
revenue_cents = revenue_by_club[row.club_id].to_i
|
||||||
|
storage_bytes = storage_by_club[row.club_id].to_i
|
||||||
|
|
||||||
|
{
|
||||||
|
club_id: row.club_id,
|
||||||
|
club_name: row.club_name,
|
||||||
|
plan_slug: club&.subscription&.plan&.slug,
|
||||||
|
sessions: sessions_count,
|
||||||
|
hours: hours.round(2),
|
||||||
|
hours_share_pct: (share * 100).round(1),
|
||||||
|
allocated_cost_cents: allocated_cents,
|
||||||
|
revenue_cents: revenue_cents,
|
||||||
|
margin_cents: revenue_cents - allocated_cents,
|
||||||
|
cost_per_hour_cents: hours.positive? ? (allocated_cents / hours).round : nil,
|
||||||
|
cost_per_session_cents: sessions_count.positive? ? (allocated_cents / sessions_count) : nil,
|
||||||
|
storage_bytes: storage_bytes
|
||||||
|
}
|
||||||
|
end.sort_by { |row| [-row[:hours], row[:club_name]] }
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def build_period(month)
|
||||||
|
range = month_range(month)
|
||||||
|
cost_cents = PlatformCostEntry.for_month(month).sum(:amount_cents)
|
||||||
|
sessions_scope = ended_sessions.where(ended_at: range)
|
||||||
|
total_secs = sessions_scope.sum(:total_duration_secs).to_i
|
||||||
|
hours = total_secs / 3600.0
|
||||||
|
sessions = sessions_scope.count
|
||||||
|
clubs_active = distinct_active_clubs(range)
|
||||||
|
revenue_cents = Billing::Payment.where(status: "paid", paid_at: range).sum(:amount_cents)
|
||||||
|
storage_bytes = Recording.ready.sum(:byte_size).to_i
|
||||||
|
storage_gb = storage_bytes.positive? ? storage_bytes / (1024.0**3) : 0.0
|
||||||
|
|
||||||
|
kpis = compute_kpis(
|
||||||
|
cost_cents: cost_cents,
|
||||||
|
hours: hours,
|
||||||
|
sessions: sessions,
|
||||||
|
clubs_active: clubs_active,
|
||||||
|
revenue_cents: revenue_cents,
|
||||||
|
storage_gb: storage_gb
|
||||||
|
)
|
||||||
|
|
||||||
|
{
|
||||||
|
month: month,
|
||||||
|
cost_cents: cost_cents,
|
||||||
|
sessions: sessions,
|
||||||
|
hours: hours.round(2),
|
||||||
|
clubs_active: clubs_active,
|
||||||
|
revenue_cents: revenue_cents,
|
||||||
|
storage_bytes: storage_bytes,
|
||||||
|
**kpis
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def compute_kpis(cost_cents:, hours:, sessions:, clubs_active:, revenue_cents:, storage_gb:)
|
||||||
|
margin_cents = revenue_cents.to_i - cost_cents.to_i
|
||||||
|
margin_pct = revenue_cents.to_i.positive? ? ((margin_cents.to_f / revenue_cents.to_i) * 100).round(1) : nil
|
||||||
|
hours_f = hours.to_f
|
||||||
|
sessions_i = sessions.to_i
|
||||||
|
clubs_i = clubs_active.to_i
|
||||||
|
storage_f = storage_gb.to_f
|
||||||
|
cost_i = cost_cents.to_i
|
||||||
|
|
||||||
|
{
|
||||||
|
cost_per_hour_cents: hours_f.positive? ? (cost_i / hours_f).round : nil,
|
||||||
|
cost_per_session_cents: sessions_i.positive? ? (cost_i / sessions_i) : nil,
|
||||||
|
cost_per_club_cents: clubs_i.positive? ? (cost_i / clubs_i) : nil,
|
||||||
|
revenue_per_hour_cents: hours_f.positive? ? (revenue_cents.to_i / hours_f).round : nil,
|
||||||
|
cost_per_gb_cents: storage_f.positive? ? (cost_i / storage_f).round : nil,
|
||||||
|
margin_cents: margin_cents,
|
||||||
|
margin_pct: margin_pct
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def month_range(month)
|
||||||
|
month.beginning_of_month.beginning_of_day..month.end_of_month.end_of_day
|
||||||
|
end
|
||||||
|
|
||||||
|
def ended_sessions
|
||||||
|
StreamSession.where(status: "ended")
|
||||||
|
end
|
||||||
|
|
||||||
|
def distinct_active_clubs(range)
|
||||||
|
ended_sessions
|
||||||
|
.where(ended_at: range)
|
||||||
|
.joins(match: { team: :club })
|
||||||
|
.distinct
|
||||||
|
.count("clubs.id")
|
||||||
|
end
|
||||||
|
|
||||||
|
def session_rows(range)
|
||||||
|
ended_sessions
|
||||||
|
.where(ended_at: range)
|
||||||
|
.joins(match: { team: :club })
|
||||||
|
.group("clubs.id", "clubs.name")
|
||||||
|
.select(
|
||||||
|
"clubs.id AS club_id",
|
||||||
|
"clubs.name AS club_name",
|
||||||
|
"COUNT(stream_sessions.id) AS sessions_count",
|
||||||
|
"SUM(stream_sessions.total_duration_secs) AS total_secs"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def revenue_by_club(range)
|
||||||
|
Billing::Payment.where(status: "paid", paid_at: range).group(:club_id).sum(:amount_cents)
|
||||||
|
end
|
||||||
|
|
||||||
|
def storage_by_club_index
|
||||||
|
Recording.ready.joins(:team).group("teams.club_id").sum(:byte_size)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Analytics
|
||||||
|
module Suppress
|
||||||
|
COOKIE_NAME = "mltv_analytics_suppress"
|
||||||
|
COOKIE_MAX_AGE = 7 * 24 * 60 * 60
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def active?(cookie_value)
|
||||||
|
cookie_value.to_s == "1"
|
||||||
|
end
|
||||||
|
|
||||||
|
def enable!(cookie_jar)
|
||||||
|
cookie_jar[COOKIE_NAME] = cookie_options(value: "1", expires: COOKIE_MAX_AGE.seconds.from_now)
|
||||||
|
end
|
||||||
|
|
||||||
|
def disable!(cookie_jar)
|
||||||
|
cookie_jar.delete(
|
||||||
|
COOKIE_NAME,
|
||||||
|
path: "/",
|
||||||
|
same_site: :lax,
|
||||||
|
secure: cookie_secure?
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def cookie_options(value:, expires:)
|
||||||
|
{
|
||||||
|
value: value,
|
||||||
|
expires: expires,
|
||||||
|
path: "/",
|
||||||
|
httponly: true,
|
||||||
|
same_site: :lax,
|
||||||
|
secure: cookie_secure?
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
def cookie_secure?
|
||||||
|
Rails.application.config.force_ssl || Rails.env.production?
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -54,6 +54,8 @@ module Ops
|
|||||||
def process_finding(finding)
|
def process_finding(finding)
|
||||||
if finding.healthy
|
if finding.healthy
|
||||||
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
|
Ops::IncidentRecorder.resolve(fingerprint: finding.fingerprint)
|
||||||
|
# overflow usa fingerprint diverse (at_max / orphan_idle / budget) rispetto al check sano
|
||||||
|
Ops::IncidentRecorder.resolve_kind(finding.kind) if finding.kind == "stream_overflow"
|
||||||
else
|
else
|
||||||
Ops::IncidentRecorder.record(
|
Ops::IncidentRecorder.record(
|
||||||
kind: finding.kind,
|
kind: finding.kind,
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ module Ops
|
|||||||
def resolve(fingerprint:)
|
def resolve(fingerprint:)
|
||||||
new.resolve(fingerprint: fingerprint)
|
new.resolve(fingerprint: fingerprint)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_kind(kind)
|
||||||
|
new.resolve_kind(kind)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def record(finding)
|
def record(finding)
|
||||||
@@ -49,6 +53,10 @@ module Ops
|
|||||||
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
|
Ops::Incident.open.where(fingerprint: fingerprint).find_each(&:resolve!)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def resolve_kind(kind)
|
||||||
|
Ops::Incident.open.where(kind: kind).find_each(&:resolve!)
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def fingerprint_for(finding)
|
def fingerprint_for(finding)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ module Recordings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def deliver_expiring_soon(user)
|
def deliver_expiring_soon(user)
|
||||||
Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user).deliver_now
|
MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_expiring_soon(recording: @recording, recipient: user))
|
||||||
rescue EOFError => e
|
rescue EOFError => e
|
||||||
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
|
Rails.logger.warn("[Recordings::NotifyExpiring] SMTP EOF on close for #{user.email}: #{e.message}")
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ module Recordings
|
|||||||
private
|
private
|
||||||
|
|
||||||
def deliver_replay_ready(user)
|
def deliver_replay_ready(user)
|
||||||
Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user).deliver_now
|
MatchLiveTv.deliver_mail(Recordings::ReplayMailer.replay_ready(recording: @recording, recipient: user))
|
||||||
rescue EOFError => e
|
rescue EOFError => e
|
||||||
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
|
# Aruba SMTP (465/SSL) chiude la socket prima del QUIT: la mail è già partita.
|
||||||
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
|
Rails.logger.warn("[Recordings::NotifyReady] SMTP EOF on close for #{user.email}: #{e.message}")
|
||||||
|
|||||||
@@ -46,10 +46,11 @@ module Recordings
|
|||||||
return Result.new(status: :pending, message: "vod_not_ready")
|
return Result.new(status: :pending, message: "vod_not_ready")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
info = enable_embed_if_needed!(info)
|
||||||
apply_verified!(info)
|
apply_verified!(info)
|
||||||
Rails.logger.info(
|
Rails.logger.info(
|
||||||
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
|
"[Recordings::VerifyYoutubeReplay] ok recording=#{@recording.id} " \
|
||||||
"youtube_video_id=#{info.video_id}"
|
"youtube_video_id=#{info.video_id} embeddable=#{info.embeddable}"
|
||||||
)
|
)
|
||||||
Result.new(status: :ok, message: "verified")
|
Result.new(status: :ok, message: "verified")
|
||||||
rescue Youtube::VodStatus::Error => e
|
rescue Youtube::VodStatus::Error => e
|
||||||
@@ -68,6 +69,7 @@ module Recordings
|
|||||||
"thumbnail_url" => info.thumbnail_url,
|
"thumbnail_url" => info.thumbnail_url,
|
||||||
"privacy_status" => info.privacy_status,
|
"privacy_status" => info.privacy_status,
|
||||||
"upload_status" => info.upload_status,
|
"upload_status" => info.upload_status,
|
||||||
|
"embeddable" => info.embeddable,
|
||||||
"verified_via" => "live_broadcast"
|
"verified_via" => "live_broadcast"
|
||||||
).compact
|
).compact
|
||||||
|
|
||||||
@@ -82,5 +84,23 @@ module Recordings
|
|||||||
|
|
||||||
@recording.update!(attrs)
|
@recording.update!(attrs)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enable_embed_if_needed!(info)
|
||||||
|
return info if info.embeddable != false
|
||||||
|
return info if info.video_id.to_s.start_with?("mock_")
|
||||||
|
|
||||||
|
session = @recording.stream_session
|
||||||
|
Youtube::BroadcastService.new(session.match.team).enable_video_embed!(
|
||||||
|
info.video_id,
|
||||||
|
privacy_status: info.privacy_status
|
||||||
|
)
|
||||||
|
refreshed = Youtube::VodStatus.new(@recording.team, channel: "team").fetch(info.video_id)
|
||||||
|
refreshed.ready ? refreshed : info
|
||||||
|
rescue Youtube::BroadcastService::Error, Youtube::VodStatus::Error => e
|
||||||
|
Rails.logger.warn(
|
||||||
|
"[Recordings::VerifyYoutubeReplay] enable_embed recording=#{@recording.id}: #{e.message}"
|
||||||
|
)
|
||||||
|
info
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module Sessions
|
||||||
|
class AssertUserConcurrent
|
||||||
|
ERROR_CODE = "user_concurrent_stream"
|
||||||
|
|
||||||
|
def self.with_lock(session, action: "start")
|
||||||
|
new(session, action: action).with_lock { yield }
|
||||||
|
end
|
||||||
|
|
||||||
|
def initialize(session, action: "start")
|
||||||
|
@session = session
|
||||||
|
@action = action.to_s
|
||||||
|
end
|
||||||
|
|
||||||
|
def with_lock
|
||||||
|
occupying = nil
|
||||||
|
|
||||||
|
User.transaction do
|
||||||
|
User.lock.find(@session.user_id) if @session.user_id.present?
|
||||||
|
occupying = occupying_session
|
||||||
|
yield if occupying.nil?
|
||||||
|
end
|
||||||
|
|
||||||
|
return if occupying.nil?
|
||||||
|
|
||||||
|
StreamConcurrencyViolation.record!(
|
||||||
|
attempted: @session,
|
||||||
|
occupying: occupying,
|
||||||
|
action: @action
|
||||||
|
)
|
||||||
|
raise Teams::EntitlementError.new(
|
||||||
|
I18n.t("api.errors.user_concurrent_stream"),
|
||||||
|
code: ERROR_CODE
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def occupying_session
|
||||||
|
return if @session.user_id.blank?
|
||||||
|
|
||||||
|
StreamSession.broadcasting
|
||||||
|
.where(user_id: @session.user_id)
|
||||||
|
.where.not(id: @session.id)
|
||||||
|
.includes(:user, match: { team: :club })
|
||||||
|
.order(Arel.sql("COALESCE(started_at, updated_at) DESC"))
|
||||||
|
.first
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -10,8 +10,10 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
cancel_timeout_job
|
cancel_timeout_job
|
||||||
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
Sessions::AssertUserConcurrent.with_lock(@session, action: "resume") do
|
||||||
@session.begin_connect! if @session.may_begin_connect?
|
# connecting finché RTMP non è online (evita lose_connection da PublisherSync)
|
||||||
|
@session.begin_connect! if @session.may_begin_connect?
|
||||||
|
end
|
||||||
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
# Recording riabilitato in PublisherSync quando RTMP è online (evita patch path prima del publisher).
|
||||||
log_event("resumed")
|
log_event("resumed")
|
||||||
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
SessionChannel.broadcast_message(@session, { type: "command", action: "resume_stream" })
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ module Sessions
|
|||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
|
Sessions::AssertUserConcurrent.with_lock(@session, action: "start") do
|
||||||
@session.begin_connect! if @session.may_begin_connect?
|
@session.match.team.entitlements.assert_concurrent_stream!(excluding_session: @session)
|
||||||
@session.update!(status: "connecting") unless @session.connecting?
|
@session.begin_connect! if @session.may_begin_connect?
|
||||||
|
@session.update!(status: "connecting") unless @session.connecting?
|
||||||
|
end
|
||||||
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
|
Youtube::LivePipeline.schedule!(@session, force: true) if @session.platform == "youtube"
|
||||||
broadcast_status("connecting")
|
broadcast_status("connecting")
|
||||||
@session
|
@session
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ module Users
|
|||||||
return if user.nil?
|
return if user.nil?
|
||||||
|
|
||||||
token = user.generate_password_reset!
|
token = user.generate_password_reset!
|
||||||
UserMailer.password_reset(user, token).deliver_now
|
MatchLiveTv.deliver_mail(UserMailer.password_reset(user, token))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -97,6 +97,24 @@ module Youtube
|
|||||||
:skipped
|
:skipped
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def enable_video_embed!(video_id, privacy_status: "unlisted")
|
||||||
|
return if video_id.blank? || video_id.to_s.start_with?("mock_")
|
||||||
|
return if @credential.blank? || missing_oauth_config?
|
||||||
|
|
||||||
|
client = authorized_client
|
||||||
|
video = Google::Apis::YoutubeV3::Video.new(
|
||||||
|
id: video_id,
|
||||||
|
status: Google::Apis::YoutubeV3::VideoStatus.new(
|
||||||
|
embeddable: true,
|
||||||
|
privacy_status: privacy_status.presence || "unlisted",
|
||||||
|
self_declared_made_for_kids: false
|
||||||
|
)
|
||||||
|
)
|
||||||
|
client.update_video("status", video)
|
||||||
|
rescue Google::Apis::Error => e
|
||||||
|
raise Error, e.message
|
||||||
|
end
|
||||||
|
|
||||||
def complete_broadcast!(broadcast_id)
|
def complete_broadcast!(broadcast_id)
|
||||||
return if broadcast_id.blank?
|
return if broadcast_id.blank?
|
||||||
return if @credential.blank? || missing_oauth_config?
|
return if @credential.blank? || missing_oauth_config?
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ module Youtube
|
|||||||
:thumbnail_url,
|
:thumbnail_url,
|
||||||
:privacy_status,
|
:privacy_status,
|
||||||
:upload_status,
|
:upload_status,
|
||||||
|
:embeddable,
|
||||||
keyword_init: true
|
keyword_init: true
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,7 +33,8 @@ module Youtube
|
|||||||
duration_secs: nil,
|
duration_secs: nil,
|
||||||
thumbnail_url: nil,
|
thumbnail_url: nil,
|
||||||
privacy_status: "unlisted",
|
privacy_status: "unlisted",
|
||||||
upload_status: "processed"
|
upload_status: "processed",
|
||||||
|
embeddable: true
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -51,7 +53,8 @@ module Youtube
|
|||||||
duration_secs: parse_duration(item.content_details&.duration),
|
duration_secs: parse_duration(item.content_details&.duration),
|
||||||
thumbnail_url: item.snippet&.thumbnails&.high&.url || item.snippet&.thumbnails&.default&.url,
|
thumbnail_url: item.snippet&.thumbnails&.high&.url || item.snippet&.thumbnails&.default&.url,
|
||||||
privacy_status: item.status&.privacy_status,
|
privacy_status: item.status&.privacy_status,
|
||||||
upload_status: upload_status
|
upload_status: upload_status,
|
||||||
|
embeddable: item.status&.embeddable != false
|
||||||
)
|
)
|
||||||
rescue Google::Apis::Error => e
|
rescue Google::Apis::Error => e
|
||||||
raise Error, e.message
|
raise Error, e.message
|
||||||
|
|||||||
@@ -5,6 +5,32 @@
|
|||||||
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
<p class="muted admin-page-sub"><%= t("admin.analytics.index.lead") %></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<section class="panel admin-analytics-preview">
|
||||||
|
<h3><%= t("admin.analytics.preview.title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.analytics.preview.lead") %></p>
|
||||||
|
<% if @analytics_preview_active %>
|
||||||
|
<p class="admin-analytics-preview__status"><%= t("admin.analytics.preview.active") %></p>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= button_to t("admin.analytics.preview.disable"),
|
||||||
|
admin_analytics_preview_path,
|
||||||
|
method: :delete,
|
||||||
|
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.analytics.preview.open_site"),
|
||||||
|
root_path,
|
||||||
|
class: "admin-btn admin-btn--primary admin-btn--sm",
|
||||||
|
target: "_blank",
|
||||||
|
rel: "noopener" %>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= button_to t("admin.analytics.preview.enable"),
|
||||||
|
admin_analytics_preview_path(return_to: root_path),
|
||||||
|
method: :post,
|
||||||
|
class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="panel admin-sessions-filters">
|
<div class="panel admin-sessions-filters">
|
||||||
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
<%= form_with url: admin_analytics_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
<div class="admin-filter-grid">
|
<div class="admin-filter-grid">
|
||||||
@@ -58,8 +84,9 @@
|
|||||||
<td class="muted"><%= avg %>%</td>
|
<td class="muted"><%= avg %>%</td>
|
||||||
<td class="muted"><%= row[:max_scroll] %>%</td>
|
<td class="muted"><%= row[:max_scroll] %>%</td>
|
||||||
<td>
|
<td>
|
||||||
<%= link_to t("admin.analytics.index.heatmap"),
|
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
|
||||||
admin_analytics_page_path(page_path: row[:page_path], from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
|
||||||
|
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="admin-page-head admin-session-head">
|
<div class="admin-page-head admin-session-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="muted admin-page-sub">
|
<p class="muted admin-page-sub">
|
||||||
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to], device: @filters[:device]) %>
|
<%= link_to t("admin.analytics.show.back"), admin_analytics_path(from: @filters[:from], to: @filters[:to]) %>
|
||||||
</p>
|
</p>
|
||||||
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
<h2 class="admin-page-title"><%= t("admin.analytics.show.title") %></h2>
|
||||||
<p><code class="admin-mono"><%= @page_path %></code></p>
|
<p><code class="admin-mono"><%= @page_path %></code></p>
|
||||||
@@ -11,8 +11,32 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel admin-sessions-filters">
|
<div class="panel admin-sessions-filters">
|
||||||
|
<p class="admin-filter-field" style="margin-bottom: 0.75rem;">
|
||||||
|
<span><%= t("admin.analytics.show.device_tabs_label") %></span>
|
||||||
|
</p>
|
||||||
|
<div class="admin-locale-tabs" role="tablist">
|
||||||
|
<% AnalyticsEvent::DEVICES.each do |device| %>
|
||||||
|
<% stats = @device_tab_stats[device] %>
|
||||||
|
<% active = @filters[:device] == device %>
|
||||||
|
<%= link_to admin_analytics_page_path(
|
||||||
|
page_path: @page_path,
|
||||||
|
from: @filters[:from],
|
||||||
|
to: @filters[:to],
|
||||||
|
device: device,
|
||||||
|
layer: @filters[:layer]
|
||||||
|
),
|
||||||
|
class: "admin-locale-tab #{'is-active' if active}",
|
||||||
|
role: "tab",
|
||||||
|
"aria-selected": active do %>
|
||||||
|
<%= t("admin.analytics.devices.#{device}") %>
|
||||||
|
<span class="admin-device-tab-count"><%= stats[:pageviews] %></span>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
<%= form_with url: admin_analytics_page_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
<%= hidden_field_tag :page_path, @page_path %>
|
<%= hidden_field_tag :page_path, @page_path %>
|
||||||
|
<%= hidden_field_tag :device, @filters[:device] %>
|
||||||
<div class="admin-filter-grid">
|
<div class="admin-filter-grid">
|
||||||
<label class="admin-filter-field">
|
<label class="admin-filter-field">
|
||||||
<span><%= t("admin.analytics.filters.from") %></span>
|
<span><%= t("admin.analytics.filters.from") %></span>
|
||||||
@@ -22,14 +46,6 @@
|
|||||||
<span><%= t("admin.analytics.filters.to") %></span>
|
<span><%= t("admin.analytics.filters.to") %></span>
|
||||||
<%= date_field_tag :to, @filters[:to] %>
|
<%= date_field_tag :to, @filters[:to] %>
|
||||||
</label>
|
</label>
|
||||||
<label class="admin-filter-field">
|
|
||||||
<span><%= t("admin.analytics.filters.device") %></span>
|
|
||||||
<%= select_tag :device,
|
|
||||||
options_for_select(
|
|
||||||
[[t("admin.analytics.filters.any"), ""]] + AnalyticsEvent::DEVICES.map { |d| [d, d] },
|
|
||||||
@filters[:device]
|
|
||||||
) %>
|
|
||||||
</label>
|
|
||||||
<label class="admin-filter-field">
|
<label class="admin-filter-field">
|
||||||
<span><%= t("admin.analytics.filters.layer") %></span>
|
<span><%= t("admin.analytics.filters.layer") %></span>
|
||||||
<%= select_tag :layer,
|
<%= select_tag :layer,
|
||||||
@@ -91,7 +107,12 @@
|
|||||||
<h3>
|
<h3>
|
||||||
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
<%= @filters[:layer] == "click" ? t("admin.analytics.show.heatmap_clicks") : t("admin.analytics.show.heatmap_moves") %>
|
||||||
</h3>
|
</h3>
|
||||||
<p class="muted admin-table-sub"><%= t("admin.analytics.show.heatmap_hint") %></p>
|
<p class="muted admin-table-sub">
|
||||||
|
<%= t(
|
||||||
|
"admin.analytics.show.heatmap_hint",
|
||||||
|
device: t("admin.analytics.devices.#{@filters[:device]}")
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
|
||||||
<% if @cells.any? %>
|
<% if @cells.any? %>
|
||||||
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
<div class="admin-heatmap-stage" id="admin-heatmap-stage">
|
||||||
@@ -115,13 +136,20 @@
|
|||||||
></canvas>
|
></canvas>
|
||||||
</div>
|
</div>
|
||||||
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
<p class="muted admin-table-sub" style="margin: 0.75rem 1rem 1rem;">
|
||||||
<%= t("admin.analytics.show.snapshot_meta",
|
<%= t(
|
||||||
device: @snapshot.device,
|
"admin.analytics.show.snapshot_meta",
|
||||||
at: l(@snapshot.captured_at, format: :short)) %>
|
device: t("admin.analytics.devices.#{@snapshot.device}"),
|
||||||
|
at: l(@snapshot.captured_at, format: :short)
|
||||||
|
) %>
|
||||||
</p>
|
</p>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
<div class="admin-heatmap-fallback" aria-hidden="true"></div>
|
||||||
<p class="muted admin-heatmap-fallback-note"><%= t("admin.analytics.show.snapshot_missing") %></p>
|
<p class="muted admin-heatmap-fallback-note">
|
||||||
|
<%= t(
|
||||||
|
"admin.analytics.show.snapshot_missing",
|
||||||
|
device: t("admin.analytics.devices.#{@filters[:device]}")
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
<canvas
|
<canvas
|
||||||
id="admin-heatmap"
|
id="admin-heatmap"
|
||||||
class="admin-heatmap-overlay"
|
class="admin-heatmap-overlay"
|
||||||
|
|||||||
@@ -46,3 +46,14 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<h3 style="font-size:1rem;margin-top:28px"><%= t("admin.clubs.show.concurrency_title") %></h3>
|
||||||
|
<p class="muted"><%= t("admin.clubs.show.concurrency_lead") %></p>
|
||||||
|
<%= render "admin/stream_concurrency_violations/table",
|
||||||
|
violations: @concurrency_violations,
|
||||||
|
empty_key: "admin.clubs.show.concurrency_none" %>
|
||||||
|
<% if @concurrency_violations.any? %>
|
||||||
|
<p class="kpi-sub" style="margin-top:0.75rem">
|
||||||
|
<%= link_to t("admin.clubs.show.concurrency_all"), admin_stream_concurrency_violations_path(q: @club.name) %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<%= form_with model: @entry, url: url, method: method, local: true, class: "admin-filter-form" do |f| %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.entries.month") %></span>
|
||||||
|
<%= text_field_tag "platform_cost_entry[month]",
|
||||||
|
@entry.month&.strftime("%Y-%m"),
|
||||||
|
type: "month",
|
||||||
|
required: true %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.entries.label") %></span>
|
||||||
|
<%= f.text_field :label, required: true, maxlength: 120 %>
|
||||||
|
<span class="muted admin-table-sub"><%= t("admin.costs.entries.label_hint") %></span>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.entries.amount") %></span>
|
||||||
|
<%= number_field_tag "platform_cost_entry[amount_euros]",
|
||||||
|
(@entry.amount_cents.to_f / 100.0 if @entry.amount_cents.present?),
|
||||||
|
step: 0.01,
|
||||||
|
min: 0.01,
|
||||||
|
required: true %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field" style="grid-column: span 2;">
|
||||||
|
<span><%= t("admin.costs.entries.notes") %></span>
|
||||||
|
<%= f.text_area :notes, rows: 3 %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= f.submit t("admin.costs.entries.save"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.costs.entries.cancel"),
|
||||||
|
admin_costs_path(month: @entry.month&.strftime("%Y-%m")),
|
||||||
|
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.costs.entries.edit_title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= @entry.label %> · <%= admin_month_label(@entry.month) %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<%= render "form", url: admin_cost_entry_path(@entry), method: :patch %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.costs.entries.new_title") %></h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<%= render "form", url: admin_cost_entries_path, method: :post %>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
<% content_for :head do %>
|
||||||
|
<script>
|
||||||
|
window.adminCostTrend = <%= raw @trend.to_json %>;
|
||||||
|
window.adminCostI18n = {
|
||||||
|
cost: <%= raw t("admin.costs.charts.cost_revenue").to_json %>,
|
||||||
|
revenue: <%= raw t("admin.costs.kpi.revenue").to_json %>,
|
||||||
|
margin: <%= raw t("admin.costs.kpi.margin").to_json %>,
|
||||||
|
costPerHour: <%= raw t("admin.costs.charts.cost_per_hour").to_json %>,
|
||||||
|
hours: <%= raw t("admin.costs.charts.hours").to_json %>
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js" crossorigin="anonymous"></script>
|
||||||
|
<script src="/admin-costs.js?v=1" defer></script>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.costs.index.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= t("admin.costs.index.lead") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel admin-sessions-filters">
|
||||||
|
<%= form_with url: admin_costs_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.costs.index.month") %></span>
|
||||||
|
<%= select_tag :month,
|
||||||
|
options_for_select(
|
||||||
|
@month_options.map { |m| [admin_month_label(m), m.strftime("%Y-%m")] },
|
||||||
|
@month.strftime("%Y-%m")
|
||||||
|
) %>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= submit_tag t("admin.costs.index.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.costs.index.add_entry"),
|
||||||
|
new_admin_cost_entry_path(month: @month.strftime("%Y-%m")),
|
||||||
|
class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<div class="kpi-card kpi-card--accent">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.platform_cost") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:cost_cents]) %></div>
|
||||||
|
<div class="kpi-sub"><%= admin_month_label(@month) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.revenue") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:revenue_cents]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.margin") %>: <%= format_euros(@summary[:margin_cents]) %>
|
||||||
|
<% if @summary[:margin_pct] %>
|
||||||
|
· <%= @summary[:margin_pct] %>%
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_hour") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:cost_per_hour_cents]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.revenue_per_hour") %>: <%= format_euros(@summary[:revenue_per_hour_cents]) %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.hours") %></div>
|
||||||
|
<div class="kpi-value"><%= format_hours(@summary[:hours]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.sessions") %>: <%= @summary[:sessions] %>
|
||||||
|
· <%= t("admin.costs.kpi.clubs_active") %>: <%= @summary[:clubs_active] %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.cost_per_session") %></div>
|
||||||
|
<div class="kpi-value"><%= format_euros(@summary[:cost_per_session_cents]) %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.costs.kpi.cost_per_club") %>: <%= format_euros(@summary[:cost_per_club_cents]) %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card">
|
||||||
|
<div class="kpi-label"><%= t("admin.costs.kpi.storage") %></div>
|
||||||
|
<div class="kpi-value" style="font-size:1.2rem"><%= format_bytes(@summary[:storage_bytes]) %></div>
|
||||||
|
<div class="kpi-sub">
|
||||||
|
<%= t("admin.costs.kpi.cost_per_gb") %>: <%= format_euros(@summary[:cost_per_gb_cents]) %>
|
||||||
|
· <%= t("admin.costs.kpi.storage_note") %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<h3><%= t("admin.costs.index.trends_title") %></h3>
|
||||||
|
<div class="charts-grid">
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><%= t("admin.costs.charts.cost_revenue") %></h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-cost-revenue"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><%= t("admin.costs.charts.cost_per_hour") %></h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-cost-hour"></canvas></div>
|
||||||
|
</div>
|
||||||
|
<div class="chart-card">
|
||||||
|
<h3><%= t("admin.costs.charts.hours") %></h3>
|
||||||
|
<div class="chart-wrap"><canvas id="chart-hours"></canvas></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<h3><%= t("admin.costs.index.entries_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.costs.index.entries_lead") %></p>
|
||||||
|
<% if @entries.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.costs.table.label") %></th>
|
||||||
|
<th><%= t("admin.costs.table.amount") %></th>
|
||||||
|
<th><%= t("admin.costs.table.notes") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @entries.each do |entry| %>
|
||||||
|
<tr>
|
||||||
|
<td><strong><%= entry.label %></strong></td>
|
||||||
|
<td><%= format_euros(entry.amount_cents) %></td>
|
||||||
|
<td class="muted"><%= entry.notes.presence || t("admin.common.dash") %></td>
|
||||||
|
<td class="admin-actions">
|
||||||
|
<%= link_to t("admin.announcements.actions.edit"),
|
||||||
|
edit_admin_cost_entry_path(entry),
|
||||||
|
class: "admin-btn admin-btn--sm" %>
|
||||||
|
<%= button_to t("admin.costs.entries.delete"),
|
||||||
|
admin_cost_entry_path(entry),
|
||||||
|
method: :delete,
|
||||||
|
class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||||
|
form: { data: { confirm: t("admin.costs.entries.delete_confirm") } } %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.costs.kpi.platform_cost") %></th>
|
||||||
|
<th><%= format_euros(@summary[:cost_cents]) %></th>
|
||||||
|
<th colspan="2"></th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.costs.index.no_entries") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.costs.index.clubs_title") %></h3>
|
||||||
|
<p class="muted admin-table-sub"><%= t("admin.costs.index.clubs_lead") %></p>
|
||||||
|
<% if @clubs.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.costs.table.club") %></th>
|
||||||
|
<th><%= t("admin.costs.table.plan") %></th>
|
||||||
|
<th><%= t("admin.costs.table.hours") %></th>
|
||||||
|
<th><%= t("admin.costs.table.hours_share") %></th>
|
||||||
|
<th><%= t("admin.costs.table.allocated_cost") %></th>
|
||||||
|
<th><%= t("admin.costs.table.revenue") %></th>
|
||||||
|
<th><%= t("admin.costs.table.margin") %></th>
|
||||||
|
<th><%= t("admin.costs.table.cost_per_hour") %></th>
|
||||||
|
<th><%= t("admin.costs.table.cost_per_session") %></th>
|
||||||
|
<th><%= t("admin.costs.table.storage") %></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% @clubs.each do |row| %>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<%= link_to row[:club_name], admin_club_path(row[:club_id]), class: "admin-table-strong" %>
|
||||||
|
</td>
|
||||||
|
<td class="muted"><%= row[:plan_slug] || t("admin.common.dash") %></td>
|
||||||
|
<td><%= format_hours(row[:hours]) %></td>
|
||||||
|
<td class="muted"><%= row[:hours_share_pct] %>%</td>
|
||||||
|
<td><%= format_euros(row[:allocated_cost_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:revenue_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:margin_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:cost_per_hour_cents]) %></td>
|
||||||
|
<td><%= format_euros(row[:cost_per_session_cents]) %></td>
|
||||||
|
<td class="muted"><%= format_bytes(row[:storage_bytes]) %></td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t("admin.costs.index.no_clubs") %></p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
@@ -67,6 +67,24 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel" style="margin-bottom:1.25rem">
|
||||||
|
<h2><%= t("admin.dashboard.concurrency_panel.title") %></h2>
|
||||||
|
<% if @concurrency_violation_lookback.positive? %>
|
||||||
|
<p class="kpi-sub" style="margin-bottom:0.75rem">
|
||||||
|
<%= t("admin.dashboard.concurrency_panel.count", count: @concurrency_violation_lookback) %>
|
||||||
|
— <%= link_to t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path %>
|
||||||
|
</p>
|
||||||
|
<%= render "admin/stream_concurrency_violations/table",
|
||||||
|
violations: @recent_concurrency_violations,
|
||||||
|
empty_key: "admin.dashboard.concurrency_panel.none" %>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty" style="margin:0">
|
||||||
|
<%= t("admin.dashboard.concurrency_panel.none_html",
|
||||||
|
link: link_to(t("admin.dashboard.concurrency_panel.view_all"), admin_stream_concurrency_violations_path)) %>
|
||||||
|
</p>
|
||||||
|
<% end %>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
|
<section class="charts-grid" style="grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));">
|
||||||
<div class="chart-card">
|
<div class="chart-card">
|
||||||
<h3><%= t("admin.dashboard.disk.system_title") %></h3>
|
<h3><%= t("admin.dashboard.disk.system_title") %></h3>
|
||||||
|
|||||||
@@ -258,6 +258,16 @@
|
|||||||
</section>
|
</section>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% if @concurrency_violations.any? %>
|
||||||
|
<section class="panel">
|
||||||
|
<h3><%= t("admin.sessions.show.concurrency_title") %></h3>
|
||||||
|
<p class="muted"><%= t("admin.sessions.show.concurrency_lead") %></p>
|
||||||
|
<%= render "admin/stream_concurrency_violations/table",
|
||||||
|
violations: @concurrency_violations,
|
||||||
|
empty_key: "admin.sessions.show.concurrency_none" %>
|
||||||
|
</section>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
||||||
<% if @events.any? %>
|
<% if @events.any? %>
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
<% if violations.any? %>
|
||||||
|
<div class="admin-table-wrap">
|
||||||
|
<table class="admin-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.when") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.account") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.club") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.occupying") %></th>
|
||||||
|
<th><%= t("admin.stream_concurrency.table.attempted") %></th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% violations.each do |row| %>
|
||||||
|
<tr class="<%= 'admin-row--two-devices' if row.devices_differ? %>">
|
||||||
|
<td class="muted"><%= admin_datetime(row.created_at, with_seconds: true) %></td>
|
||||||
|
<td>
|
||||||
|
<strong><%= row.user_name.presence || t("admin.common.dash") %></strong>
|
||||||
|
<div class="muted" style="font-size:0.85rem"><%= row.user_email %></div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.attempted_club %>
|
||||||
|
<%= link_to row.club_name, admin_club_path(row.attempted_club) %>
|
||||||
|
<% elsif row.occupying_club %>
|
||||||
|
<%= link_to row.club_name, admin_club_path(row.occupying_club) %>
|
||||||
|
<% else %>
|
||||||
|
<%= row.club_name.presence || t("admin.common.dash") %>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.occupying_session %>
|
||||||
|
<%= link_to row.occupying_match_label, admin_session_path(row.occupying_session) %>
|
||||||
|
<% else %>
|
||||||
|
<%= row.occupying_match_label %>
|
||||||
|
<% end %>
|
||||||
|
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
|
||||||
|
<span class="badge <%= admin_session_status_badge_class(row.occupying_status) %>"><%= row.occupying_status %></span>
|
||||||
|
<%= row.occupying_device.presence || t("admin.common.dash") %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.attempted_session %>
|
||||||
|
<%= link_to row.attempted_match_label, admin_session_path(row.attempted_session) %>
|
||||||
|
<% else %>
|
||||||
|
<%= row.attempted_match_label %>
|
||||||
|
<% end %>
|
||||||
|
<div class="muted" style="font-size:0.85rem;margin-top:0.2rem">
|
||||||
|
<%= t("admin.stream_concurrency.action.#{row.attempt_action}") %>
|
||||||
|
· <%= row.attempted_device.presence || t("admin.common.dash") %>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<% if row.devices_differ? %>
|
||||||
|
<span class="badge badge--abuse"><%= t("admin.stream_concurrency.badge.two_devices") %></span>
|
||||||
|
<% else %>
|
||||||
|
<span class="muted"><%= t("admin.stream_concurrency.badge.same_or_unknown") %></span>
|
||||||
|
<% end %>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<% end %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<% else %>
|
||||||
|
<p class="empty"><%= t(empty_key) %></p>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<% content_for :body_class, "admin-body" %>
|
||||||
|
|
||||||
|
<div class="admin-page-head">
|
||||||
|
<h2 class="admin-page-title"><%= t("admin.stream_concurrency.index.title") %></h2>
|
||||||
|
<p class="muted admin-page-sub"><%= t("admin.stream_concurrency.index.lead") %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="kpi-grid">
|
||||||
|
<div class="kpi-card <%= @lookback_count.positive? ? 'kpi-card--accent' : '' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.lookback") %></div>
|
||||||
|
<div class="kpi-value"><%= @lookback_count %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.lookback_sub") %></div>
|
||||||
|
</div>
|
||||||
|
<div class="kpi-card <%= @two_devices_count.positive? ? 'kpi-card--danger' : '' %>">
|
||||||
|
<div class="kpi-label"><%= t("admin.stream_concurrency.kpi.two_devices") %></div>
|
||||||
|
<div class="kpi-value"><%= @two_devices_count %></div>
|
||||||
|
<div class="kpi-sub"><%= t("admin.stream_concurrency.kpi.two_devices_sub") %></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="panel admin-sessions-filters">
|
||||||
|
<%= form_with url: admin_stream_concurrency_violations_path, method: :get, local: true, class: "admin-filter-form" do %>
|
||||||
|
<div class="admin-filter-grid">
|
||||||
|
<label class="admin-filter-field">
|
||||||
|
<span><%= t("admin.stream_concurrency.filters.q") %></span>
|
||||||
|
<%= text_field_tag :q, @filters[:q], placeholder: t("admin.stream_concurrency.filters.q_placeholder") %>
|
||||||
|
</label>
|
||||||
|
<label class="admin-filter-field admin-filter-field--check">
|
||||||
|
<span><%= t("admin.stream_concurrency.filters.two_devices") %></span>
|
||||||
|
<label class="admin-checkbox">
|
||||||
|
<%= check_box_tag :devices_differ, "1", @filters[:devices_differ] %>
|
||||||
|
<%= t("admin.stream_concurrency.filters.two_devices_hint") %>
|
||||||
|
</label>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="admin-filter-actions">
|
||||||
|
<%= submit_tag t("admin.stream_concurrency.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||||
|
<%= link_to t("admin.stream_concurrency.filters.reset"), admin_stream_concurrency_violations_path, class: "admin-btn admin-btn--outline admin-btn--sm" %>
|
||||||
|
<span class="muted admin-filter-count">
|
||||||
|
<%= t("admin.stream_concurrency.index.results", shown: @violations.size, total: @total_count) %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<%= render "admin/stream_concurrency_violations/table", violations: @violations, empty_key: "admin.stream_concurrency.index.none" %>
|
||||||
|
</div>
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<link rel="stylesheet" href="/admin.css?v=15">
|
<link rel="stylesheet" href="/admin.css?v=16">
|
||||||
|
<%= yield :head %>
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
@@ -31,7 +32,12 @@
|
|||||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||||
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
<%= link_to t("admin.layout.nav.sessions"), admin_sessions_path, class: ("active" if controller_name == "sessions") %>
|
||||||
|
<% abuse_count = admin_concurrency_violation_lookback_count %>
|
||||||
|
<%= link_to admin_stream_concurrency_violations_path, class: ("active" if controller_name == "stream_concurrency_violations") do %>
|
||||||
|
<%= t("admin.layout.nav.stream_concurrency") %><% if abuse_count.positive? %> <span class="admin-nav-badge"><%= abuse_count %></span><% end %>
|
||||||
|
<% end %>
|
||||||
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
|
<%= link_to t("admin.layout.nav.analytics"), admin_analytics_path, class: ("active" if controller_name == "analytics") %>
|
||||||
|
<%= link_to t("admin.layout.nav.costs"), admin_costs_path, class: ("active" if controller_name.in?(%w[costs cost_entries])) %>
|
||||||
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
<%= link_to t("admin.layout.nav.stream_nodes"), admin_stream_nodes_path, class: ("active" if controller_name == "stream_nodes") %>
|
||||||
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
<%= link_to t("admin.layout.nav.password"), edit_admin_password_path %>
|
||||||
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
<%= button_to t("admin.layout.nav.logout"), admin_logout_path, method: :delete %>
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
<meta name="application-name" content="Match Live TV">
|
<meta name="application-name" content="Match Live TV">
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
|
<%= render "shared/analytics_suppress" %>
|
||||||
<%= 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=76">
|
<link rel="stylesheet" href="/marketing.css?v=81">
|
||||||
</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" %>
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
<script src="/password-toggle.js?v=2" defer></script>
|
<script src="/password-toggle.js?v=2" defer></script>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=4" defer></script>
|
<script src="/site-analytics.js?v=6" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||||
<%= csrf_meta_tags %>
|
<%= csrf_meta_tags %>
|
||||||
<%= render "shared/meta_tags" %>
|
<%= render "shared/meta_tags" %>
|
||||||
|
<%= render "shared/analytics_suppress" %>
|
||||||
<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=76">
|
<link rel="stylesheet" href="/marketing.css?v=81">
|
||||||
<link rel="stylesheet" href="/live.css?v=26">
|
<link rel="stylesheet" href="/live.css?v=26">
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
</head>
|
</head>
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
<%= render "shared/marketing_footer" %>
|
<%= render "shared/marketing_footer" %>
|
||||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||||
<script src="/confirm-forms.js?v=7" defer></script>
|
<script src="/confirm-forms.js?v=7" defer></script>
|
||||||
<script src="/site-analytics.js?v=4" defer></script>
|
<script src="/site-analytics.js?v=6" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<%= hidden_field_tag :plan, plan_return if plan_return %>
|
<%= hidden_field_tag :plan, plan_return if plan_return %>
|
||||||
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
|
<%= hidden_field_tag :interval, params[:interval] if params[:interval].present? %>
|
||||||
<%= render "shared/billing_profile_fields", record: @club %>
|
<%= render "shared/billing_profile_fields", record: @club %>
|
||||||
|
<% if plan_return %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "checkout" %>
|
||||||
|
<% end %>
|
||||||
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
|
<%= submit_tag t("billing.profile.submit"), class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,6 +21,9 @@
|
|||||||
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
|
<%= render "shared/quoted_price_banner", quote: @quote, subscription: @subscription %>
|
||||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||||
<%= render "shared/stripe_secure_payment" %>
|
<%= render "shared/stripe_secure_payment" %>
|
||||||
|
<% if @subscription.blank? || @subscription.plan&.slug == "free" || !@subscription.active? %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "checkout" %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
|
<%= render "shared/pending_bank_transfer", order: @pending_transfer %>
|
||||||
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
|
<% if MatchLiveTv.stripe_enabled? && @quote.blank? %>
|
||||||
|
|||||||
@@ -26,6 +26,9 @@
|
|||||||
[t("club.new.plan_full"), "premium_full"]
|
[t("club.new.plan_full"), "premium_full"]
|
||||||
], params[:plan] || "free") %>
|
], params[:plan] || "free") %>
|
||||||
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
|
<p class="muted" style="margin:8px 0 16px"><%= t("club.new.plan_hint") %></p>
|
||||||
|
<% if params[:plan].presence_in(%w[premium_light premium_full]) %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "checkout" %>
|
||||||
|
<% end %>
|
||||||
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
|
<%= submit_tag t("club.new.submit"), class: "btn btn-primary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -164,8 +164,8 @@
|
|||||||
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
|
<aside class="demo-live-card" aria-label="<%= t("live.index.demo_aria_label") %>">
|
||||||
<p class="demo-label"><%= t("live.index.demo_label") %></p>
|
<p class="demo-label"><%= t("live.index.demo_label") %></p>
|
||||||
<article class="live-card live-card--demo">
|
<article class="live-card live-card--demo">
|
||||||
<h3>Tigers Volley vs ASD Eagles</h3>
|
<h3><%= MatchLiveTv::Demo.match_title %></h3>
|
||||||
<p class="meta"><%= t("live.index.demo_meta") %></p>
|
<p class="meta"><%= MatchLiveTv::Demo.live_meta %></p>
|
||||||
<p class="card-score">
|
<p class="card-score">
|
||||||
<span class="card-sets"><%= t("live.index.demo_sets") %></span>
|
<span class="card-sets"><%= t("live.index.demo_sets") %></span>
|
||||||
<span class="card-points">18 - 16</span>
|
<span class="card-points">18 - 16</span>
|
||||||
|
|||||||
@@ -70,7 +70,31 @@
|
|||||||
<%= t("pages.faq.q8_answer") %>
|
<%= t("pages.faq.q8_answer") %>
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item" id="faq-garanzia" data-mltv-event="guarantee_faq_open">
|
||||||
|
<summary><%= t("pages.faq.q9_question") %></summary>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"pages.faq.q9_answer_html",
|
||||||
|
terms_link: link_to(t("pages.faq.q9_terms_link"), public_termini_path(anchor: "garanzia-rimborso"))
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="faq-item">
|
||||||
|
<summary><%= t("pages.faq.q10_question") %></summary>
|
||||||
|
<p>
|
||||||
|
<%= t("pages.faq.q10_answer") %>
|
||||||
|
</p>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
if (location.hash !== "#faq-garanzia") return;
|
||||||
|
var item = document.getElementById("faq-garanzia");
|
||||||
|
if (item) item.open = true;
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
<p style="text-align:center;margin:40px 0">
|
<p style="text-align:center;margin:40px 0">
|
||||||
<%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
<%= link_to t("pages.faq.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@
|
|||||||
<div class="features-yt-mock__meta">
|
<div class="features-yt-mock__meta">
|
||||||
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
|
<span class="features-yt-mock__avatar"><i class="fa-solid fa-shield-halved"></i></span>
|
||||||
<div class="features-yt-mock__text">
|
<div class="features-yt-mock__text">
|
||||||
<strong><%= t("pages.features.youtube_mock_channel") %></strong>
|
<strong><%= MatchLiveTv::Demo.home_team %></strong>
|
||||||
<span><%= t("pages.features.youtube_mock_subs") %></span>
|
<span><%= t("pages.features.youtube_mock_subs") %></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,6 +196,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%= render "shared/sponsor_cover_promo" %>
|
||||||
|
|
||||||
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
<section class="section wrap features-replay" aria-labelledby="features-replay-title">
|
||||||
<div class="features-split features-split--reverse">
|
<div class="features-split features-split--reverse">
|
||||||
<div class="features-split__copy">
|
<div class="features-split__copy">
|
||||||
@@ -218,7 +220,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--a"></span>
|
||||||
<span class="features-archive-mock__info">
|
<span class="features-archive-mock__info">
|
||||||
<strong><%= t("pages.features.replay_mock_1_title") %></strong>
|
<strong><%= MatchLiveTv::Demo.match_title %></strong>
|
||||||
<em><%= t("pages.features.replay_mock_1_meta") %></em>
|
<em><%= t("pages.features.replay_mock_1_meta") %></em>
|
||||||
</span>
|
</span>
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
@@ -226,7 +228,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--b"></span>
|
||||||
<span class="features-archive-mock__info">
|
<span class="features-archive-mock__info">
|
||||||
<strong><%= t("pages.features.replay_mock_2_title") %></strong>
|
<strong><%= MatchLiveTv::Demo.match_title %></strong>
|
||||||
<em><%= t("pages.features.replay_mock_2_meta") %></em>
|
<em><%= t("pages.features.replay_mock_2_meta") %></em>
|
||||||
</span>
|
</span>
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
@@ -234,7 +236,7 @@
|
|||||||
<li>
|
<li>
|
||||||
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
|
<span class="features-archive-mock__thumb features-archive-mock__thumb--c"></span>
|
||||||
<span class="features-archive-mock__info">
|
<span class="features-archive-mock__info">
|
||||||
<strong><%= t("pages.features.replay_mock_3_title") %></strong>
|
<strong><%= MatchLiveTv::Demo.match_title %></strong>
|
||||||
<em><%= t("pages.features.replay_mock_3_meta") %></em>
|
<em><%= t("pages.features.replay_mock_3_meta") %></em>
|
||||||
</span>
|
</span>
|
||||||
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
<i class="fa-solid fa-download" aria-hidden="true"></i>
|
||||||
|
|||||||
@@ -94,6 +94,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%= render "shared/sponsor_cover_promo", variant: :compact %>
|
||||||
|
|
||||||
<section class="section wrap plans-teaser">
|
<section class="section wrap plans-teaser">
|
||||||
<h2><%= t("home.plans_title") %></h2>
|
<h2><%= t("home.plans_title") %></h2>
|
||||||
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
|
<p class="plans-teaser-lead"><%= t("home.plans_lead") %></p>
|
||||||
@@ -101,6 +103,7 @@
|
|||||||
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
|
<%= image_tag "/home-piani-ecosistema.png?v=1", alt: t("home.plans_alt"), class: "plans-teaser-img", loading: "lazy" %>
|
||||||
</div>
|
</div>
|
||||||
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
|
<%= link_to t("home.plans_cta"), public_prezzi_path, class: "btn btn-primary" %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "cta" %>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="section wrap seo-prose">
|
<section class="section wrap seo-prose">
|
||||||
|
|||||||
@@ -22,6 +22,8 @@
|
|||||||
|
|
||||||
<%= render "shared/plan_cards" %>
|
<%= render "shared/plan_cards" %>
|
||||||
|
|
||||||
|
<%= render "shared/sponsor_cover_promo", variant: :compact, show_cta: false, nested: true %>
|
||||||
|
|
||||||
<div class="table-scroll compare-table-wrap">
|
<div class="table-scroll compare-table-wrap">
|
||||||
<table class="compare-table">
|
<table class="compare-table">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
@@ -50,6 +52,12 @@
|
|||||||
<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_cover_sponsor") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_no") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_no") %></td>
|
||||||
|
<td><%= t("pages.pricing.table_yes") %></td>
|
||||||
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td><%= t("pages.pricing.table_price") %></td>
|
<td><%= t("pages.pricing.table_price") %></td>
|
||||||
<td><%= t("pages.pricing.table_price_free") %></td>
|
<td><%= t("pages.pricing.table_price_free") %></td>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
<div class="wrap legal-doc">
|
<div class="wrap legal-doc">
|
||||||
<h1><%= t("legal.terms.h1") %></h1>
|
<h1><%= t("legal.terms.h1") %></h1>
|
||||||
<p class="legal-meta"><%= t("legal.terms.meta", date: legal_last_updated) %></p>
|
<p class="legal-meta"><%= t("legal.terms.meta", date: terms_last_updated) %></p>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.terms.s1_title") %></h2>
|
<h2><%= t("legal.terms.s1_title") %></h2>
|
||||||
@@ -43,6 +43,21 @@
|
|||||||
<p><%= t("legal.terms.s3_p3") %></p>
|
<p><%= t("legal.terms.s3_p3") %></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="garanzia-rimborso">
|
||||||
|
<h2><%= t("legal.terms.s3b_title") %></h2>
|
||||||
|
<p><%= t("legal.terms.s3b_p1") %></p>
|
||||||
|
<p><%= t("legal.terms.s3b_p2") %></p>
|
||||||
|
<p>
|
||||||
|
<%= raw t(
|
||||||
|
"legal.terms.s3b_p3_html",
|
||||||
|
email_link: link_to(MatchLiveTv.support_email, "mailto:#{MatchLiveTv.support_email}")
|
||||||
|
) %>
|
||||||
|
</p>
|
||||||
|
<p><%= t("legal.terms.s3b_p4") %></p>
|
||||||
|
<p><%= t("legal.terms.s3b_p5") %></p>
|
||||||
|
<p><%= t("legal.terms.s3b_p6") %></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<h2><%= t("legal.terms.s4_title") %></h2>
|
<h2><%= t("legal.terms.s4_title") %></h2>
|
||||||
<p><strong><%= t("legal.terms.s4_lead1") %></strong></p>
|
<p><strong><%= t("legal.terms.s4_lead1") %></strong></p>
|
||||||
|
|||||||
@@ -14,33 +14,6 @@
|
|||||||
<h2><%= t("replay.show.processing_title") %></h2>
|
<h2><%= t("replay.show.processing_title") %></h2>
|
||||||
<p><%= t("replay.show.processing_body") %></p>
|
<p><%= t("replay.show.processing_body") %></p>
|
||||||
</div>
|
</div>
|
||||||
<% elsif @recording.ready? && @recording.replay_source == "youtube" && @recording.youtube_video_id.present? && !@recording.youtube_video_id.to_s.start_with?("mock_") %>
|
|
||||||
<div class="live-player-wrap live-player-wrap--embed">
|
|
||||||
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
|
|
||||||
title="<%= @recording.title_or_default %>"
|
|
||||||
class="live-player-embed"
|
|
||||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
|
||||||
allowfullscreen></iframe>
|
|
||||||
<%= render "public/live/player_overlays",
|
|
||||||
match: @match,
|
|
||||||
session: @session,
|
|
||||||
stream_closed: true,
|
|
||||||
on_air: false,
|
|
||||||
badge_label: t("replay.show.badge_label"),
|
|
||||||
badge_class: "badge-ended" %>
|
|
||||||
</div>
|
|
||||||
<p class="replay-show__meta-line">
|
|
||||||
<%= l_local(@recording.recorded_at_or_fallback) %>
|
|
||||||
· <%= t("replay.show.meta_line_duration", value: @recording.duration_label) %>
|
|
||||||
· <%= @recording.views_label %>
|
|
||||||
<% if @recording.source_platform_label != "—" %>
|
|
||||||
· <%= @recording.source_platform_label %>
|
|
||||||
<% end %>
|
|
||||||
</p>
|
|
||||||
<p class="replay-show__meta-line">
|
|
||||||
<%= t("replay.show.youtube_only_body") %>
|
|
||||||
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
|
|
||||||
</p>
|
|
||||||
<% elsif @recording.ready? && @recording.storage_key.present? %>
|
<% elsif @recording.ready? && @recording.storage_key.present? %>
|
||||||
<div class="live-player-wrap">
|
<div class="live-player-wrap">
|
||||||
<video id="replay-player" controls playsinline preload="metadata"
|
<video id="replay-player" controls playsinline preload="metadata"
|
||||||
@@ -80,6 +53,33 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% elsif @recording.ready? && @recording.replay_source == "youtube" && @recording.youtube_video_id.present? && !@recording.youtube_video_id.to_s.start_with?("mock_") %>
|
||||||
|
<div class="live-player-wrap live-player-wrap--embed">
|
||||||
|
<iframe src="https://www.youtube.com/embed/<%= @recording.youtube_video_id %>"
|
||||||
|
title="<%= @recording.title_or_default %>"
|
||||||
|
class="live-player-embed"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen></iframe>
|
||||||
|
<%= render "public/live/player_overlays",
|
||||||
|
match: @match,
|
||||||
|
session: @session,
|
||||||
|
stream_closed: true,
|
||||||
|
on_air: false,
|
||||||
|
badge_label: t("replay.show.badge_label"),
|
||||||
|
badge_class: "badge-ended" %>
|
||||||
|
</div>
|
||||||
|
<p class="replay-show__meta-line">
|
||||||
|
<%= l_local(@recording.recorded_at_or_fallback) %>
|
||||||
|
· <%= t("replay.show.meta_line_duration", value: @recording.duration_label) %>
|
||||||
|
· <%= @recording.views_label %>
|
||||||
|
<% if @recording.source_platform_label != "—" %>
|
||||||
|
· <%= @recording.source_platform_label %>
|
||||||
|
<% end %>
|
||||||
|
</p>
|
||||||
|
<p class="replay-show__meta-line">
|
||||||
|
<%= t("replay.show.youtube_only_body") %>
|
||||||
|
<%= link_to t("replay.show.youtube_link"), @recording.youtube_watch_url, target: "_blank", rel: "noopener" %>
|
||||||
|
</p>
|
||||||
<% elsif @recording.ready? %>
|
<% elsif @recording.ready? %>
|
||||||
<div class="stream-ended" role="status">
|
<div class="stream-ended" role="status">
|
||||||
<h2><%= t("replay.show.file_missing_title") %></h2>
|
<h2><%= t("replay.show.file_missing_title") %></h2>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<% if Analytics::Suppress.active?(request.cookie_jar[Analytics::Suppress::COOKIE_NAME]) %>
|
||||||
|
<meta name="mltv-analytics-suppress" content="1">
|
||||||
|
<div class="mltv-preview-badge" role="status">
|
||||||
|
<span><%= t("ui.analytics_preview.badge") %></span>
|
||||||
|
<%= link_to t("ui.analytics_preview.manage"), admin_analytics_path, class: "mltv-preview-badge__link" %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false) %>
|
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false, show_guarantee_banner: nil) %>
|
||||||
<% club ||= @club %>
|
<% club ||= @club %>
|
||||||
<% entitlements ||= @entitlements %>
|
<% entitlements ||= @entitlements %>
|
||||||
<% subscription ||= @subscription %>
|
<% subscription ||= @subscription %>
|
||||||
@@ -65,10 +65,16 @@
|
|||||||
t("pages.plans.youtube_none")
|
t("pages.plans.youtube_none")
|
||||||
end
|
end
|
||||||
) %></li>
|
) %></li>
|
||||||
|
<% if plan.slug == "premium_full" %>
|
||||||
|
<li><%= raw t("pages.plans.cover_sponsor_html") %></li>
|
||||||
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
<% if plan.slug == "premium_full" %>
|
<% if plan.slug == "premium_full" %>
|
||||||
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
|
<p class="plan-staff-note"><%= t("pages.plans.staff_note_full") %></p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% first_purchase = !billing_mode || action&.fetch(:kind, nil).in?(%i[checkout_options bank_only quoted]) %>
|
||||||
|
<% show_cta_guarantee = plan.slug.in?(%w[premium_light premium_full]) && first_purchase %>
|
||||||
|
<div class="plan-card__cta">
|
||||||
<% if billing_mode %>
|
<% if billing_mode %>
|
||||||
<% action_kind = action[:kind] %>
|
<% action_kind = action[:kind] %>
|
||||||
<% quote = club&.active_billing_quote %>
|
<% quote = club&.active_billing_quote %>
|
||||||
@@ -131,6 +137,14 @@
|
|||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<% if show_cta_guarantee %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "compact" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<% if local_assigns.fetch(:show_guarantee_banner) { !billing_mode || current_slug.to_s == "free" } %>
|
||||||
|
<%= render "shared/refund_guarantee", variant: "pricing" %>
|
||||||
|
<% end %>
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<%# locals: (variant: "pricing") %>
|
||||||
|
<% variant = local_assigns.fetch(:variant, "pricing").to_s %>
|
||||||
|
|
||||||
|
<% if variant == "pricing" %>
|
||||||
|
<aside class="refund-guarantee refund-guarantee--pricing" aria-label="<%= t("guarantee.aria_pricing") %>">
|
||||||
|
<span class="refund-guarantee__icon" aria-hidden="true">
|
||||||
|
<i class="fa-solid fa-shield-halved"></i>
|
||||||
|
</span>
|
||||||
|
<div class="refund-guarantee__copy">
|
||||||
|
<p class="refund-guarantee__title">
|
||||||
|
<%= t("guarantee.title") %><span class="refund-guarantee__kicker"> · <%= t("guarantee.kicker_days") %></span>
|
||||||
|
</p>
|
||||||
|
<p class="refund-guarantee__subtitle"><%= t("guarantee.subtitle") %></p>
|
||||||
|
<p class="refund-guarantee__body"><%= t("guarantee.body") %></p>
|
||||||
|
<p class="refund-guarantee__more">
|
||||||
|
<%= link_to t("guarantee.learn_more"), public_faq_path(anchor: "faq-garanzia"), data: { mltv_event_click: "guarantee_learn_more" } %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<% elsif variant == "compact" %>
|
||||||
|
<p class="refund-guarantee refund-guarantee--compact">
|
||||||
|
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
|
||||||
|
<span><%= t("guarantee.compact") %></span>
|
||||||
|
</p>
|
||||||
|
<% elsif variant == "cta" %>
|
||||||
|
<p class="refund-guarantee refund-guarantee--cta">
|
||||||
|
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
|
||||||
|
<span><%= t("guarantee.home_note") %></span>
|
||||||
|
</p>
|
||||||
|
<% elsif variant == "checkout" %>
|
||||||
|
<div class="refund-guarantee refund-guarantee--checkout">
|
||||||
|
<i class="fa-solid fa-shield-halved" aria-hidden="true"></i>
|
||||||
|
<p><%= t("guarantee.checkout") %></p>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<figure class="cover-mock" aria-hidden="true" inert>
|
||||||
|
<div class="cover-mock__chrome">
|
||||||
|
<span class="cover-mock__brand">MATCH <em>LIVE</em> TV</span>
|
||||||
|
<span class="cover-mock__state"><%= t("pages.sponsor_cover.mock.state") %></span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-mock__stage">
|
||||||
|
<p class="cover-mock__cat"><%= MatchLiveTv::Demo.category %></p>
|
||||||
|
<p class="cover-mock__home"><%= MatchLiveTv::Demo.home_team %></p>
|
||||||
|
<p class="cover-mock__vs">vs</p>
|
||||||
|
<p class="cover-mock__away"><%= MatchLiveTv::Demo.away_team %></p>
|
||||||
|
<p class="cover-mock__when"><%= MatchLiveTv::Demo.when_label %></p>
|
||||||
|
<p class="cover-mock__art-label"><%= t("pages.sponsor_cover.mock.art_label") %></p>
|
||||||
|
<div class="cover-mock__art">
|
||||||
|
<div class="cover-mock__crest">
|
||||||
|
<span class="cover-mock__crest-mark">M</span>
|
||||||
|
<span class="cover-mock__crest-name"><%= MatchLiveTv::Demo.home_team %></span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-mock__marks">
|
||||||
|
<span class="cover-mock__mark cover-mock__mark--a"></span>
|
||||||
|
<span class="cover-mock__mark cover-mock__mark--b"></span>
|
||||||
|
<span class="cover-mock__mark cover-mock__mark--c"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="cover-mock__soon"><%= t("pages.sponsor_cover.mock.soon") %></p>
|
||||||
|
</div>
|
||||||
|
</figure>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<%# locals: (variant: :full, show_cta: nil, nested: false) %>
|
||||||
|
<% variant = (local_assigns[:variant] || :full).to_sym %>
|
||||||
|
<% compact = variant == :compact %>
|
||||||
|
<% nested = local_assigns[:nested] %>
|
||||||
|
<% show_cta = local_assigns.fetch(:show_cta, true) %>
|
||||||
|
<% title_id = compact ? "sponsor-cover-title-compact" : "sponsor-cover-title" %>
|
||||||
|
|
||||||
|
<section class="section<%= " wrap" unless nested %> sponsor-cover<%= " sponsor-cover--compact" if compact %>" aria-labelledby="<%= title_id %>">
|
||||||
|
<div class="sponsor-cover__panel">
|
||||||
|
<div class="sponsor-cover__copy">
|
||||||
|
<p class="feature-card__badge feature-card__badge--gold"><%= t("pages.sponsor_cover.eyebrow") %></p>
|
||||||
|
<h2 id="<%= title_id %>"><%= t("pages.sponsor_cover.title") %></h2>
|
||||||
|
<p class="sponsor-cover__lead"><%= t("pages.sponsor_cover.body") %></p>
|
||||||
|
<% unless compact %>
|
||||||
|
<ul class="features-checklist">
|
||||||
|
<li><%= t("pages.sponsor_cover.item_cover") %></li>
|
||||||
|
<li><%= t("pages.sponsor_cover.item_before") %></li>
|
||||||
|
<li><%= t("pages.sponsor_cover.item_club") %></li>
|
||||||
|
</ul>
|
||||||
|
<p class="sponsor-cover__claim"><%= t("pages.sponsor_cover.claim") %></p>
|
||||||
|
<% end %>
|
||||||
|
<% if show_cta %>
|
||||||
|
<%= link_to t("pages.sponsor_cover.cta"), public_prezzi_path, class: "btn btn-outline" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<div class="sponsor-cover__visual">
|
||||||
|
<%= render "shared/sponsor_cover_mock" %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
require "net/smtp"
|
||||||
|
require "openssl"
|
||||||
|
|
||||||
module MatchLiveTv
|
module MatchLiveTv
|
||||||
class << self
|
class << self
|
||||||
def jwt_secret
|
def jwt_secret
|
||||||
@@ -102,6 +105,7 @@ module MatchLiveTv
|
|||||||
end
|
end
|
||||||
|
|
||||||
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
|
# In produzione senza SMTP non blocca il flusso (es. collaudo): la mail si può inviare a mano.
|
||||||
|
# Errori SMTP (dominio invalido, EOF Aruba, porta chiusa) non devono far crashare la request.
|
||||||
def deliver_mail(mail)
|
def deliver_mail(mail)
|
||||||
if Rails.env.production? && !smtp_configured?
|
if Rails.env.production? && !smtp_configured?
|
||||||
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
|
Rails.logger.info("[mail] skip (SMTP assente): #{mail.subject}")
|
||||||
@@ -110,6 +114,9 @@ module MatchLiveTv
|
|||||||
|
|
||||||
mail.deliver_now
|
mail.deliver_now
|
||||||
true
|
true
|
||||||
|
rescue EOFError, Net::SMTPError, Errno::ECONNREFUSED, Errno::ETIMEDOUT, SocketError, OpenSSL::SSL::SSLError => e
|
||||||
|
Rails.logger.warn("[mail] delivery failed: #{mail.subject} #{e.class}: #{e.message}")
|
||||||
|
false
|
||||||
end
|
end
|
||||||
|
|
||||||
def privacy_controller_name
|
def privacy_controller_name
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ de:
|
|||||||
billing: Abrechnung
|
billing: Abrechnung
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
|
stream_concurrency: Konto-Missbrauch
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Kosten
|
||||||
stream_nodes: Stream-Knoten
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
@@ -50,6 +52,9 @@ de:
|
|||||||
announcement_created: Hinweis gespeichert
|
announcement_created: Hinweis gespeichert
|
||||||
announcement_updated: Hinweis aktualisiert
|
announcement_updated: Hinweis aktualisiert
|
||||||
announcement_destroyed: Hinweis gelöscht
|
announcement_destroyed: Hinweis gelöscht
|
||||||
|
cost_entry_created: Kostenposition gespeichert.
|
||||||
|
cost_entry_updated: Kostenposition aktualisiert.
|
||||||
|
cost_entry_destroyed: Kostenposition gelöscht.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Ja"
|
yes: "Ja"
|
||||||
@@ -99,6 +104,12 @@ de:
|
|||||||
warnings: "%{count} Warnung(en)"
|
warnings: "%{count} Warnung(en)"
|
||||||
dashboard_link: Ops-Dashboard
|
dashboard_link: Ops-Dashboard
|
||||||
none_html: "Keine offenen Vorfälle. %{link}"
|
none_html: "Keine offenen Vorfälle. %{link}"
|
||||||
|
concurrency_panel:
|
||||||
|
title: Konto-Missbrauch (zweite Direktübertragung)
|
||||||
|
count: "%{count} Versuch(e) in den letzten 30 Tagen"
|
||||||
|
view_all: Alle ansehen
|
||||||
|
none: Keine Versuche erfasst.
|
||||||
|
none_html: "Keine Versuche einer zweiten Direktübertragung mit demselben Konto. %{link}"
|
||||||
disk:
|
disk:
|
||||||
system_title: Systemfestplatte
|
system_title: Systemfestplatte
|
||||||
free_label: "Frei: %{free} (%{percent}% belegt)"
|
free_label: "Frei: %{free} (%{percent}% belegt)"
|
||||||
@@ -189,6 +200,36 @@ de:
|
|||||||
table:
|
table:
|
||||||
resolved_at: Gelöst
|
resolved_at: Gelöst
|
||||||
none: Kürzlich keine Vorfälle gelöst.
|
none: Kürzlich keine Vorfälle gelöst.
|
||||||
|
stream_concurrency:
|
||||||
|
index:
|
||||||
|
title: Konto-Missbrauch
|
||||||
|
lead: Versuche, mit demselben Konto eine zweite Direktübertragung zu starten, während bereits eine lief (connecting, live, reconnecting oder pausiert).
|
||||||
|
results: "%{shown} von %{total} angezeigt"
|
||||||
|
none: Keine Versuche erfasst.
|
||||||
|
kpi:
|
||||||
|
lookback: Letzte 30 Tage
|
||||||
|
lookback_sub: blockierte Versuche
|
||||||
|
two_devices: Zwei Geräte
|
||||||
|
two_devices_sub: unterschiedliche Handy-Modelle
|
||||||
|
filters:
|
||||||
|
q: Suche
|
||||||
|
q_placeholder: E-Mail, Verein, Spiel…
|
||||||
|
two_devices: Filter
|
||||||
|
two_devices_hint: Nur Versuche von unterschiedlichen Geräten
|
||||||
|
apply: Filtern
|
||||||
|
reset: Zurücksetzen
|
||||||
|
table:
|
||||||
|
when: Wann
|
||||||
|
account: Konto
|
||||||
|
club: Verein
|
||||||
|
occupying: Bereits laufende Direktübertragung
|
||||||
|
attempted: Blockierter Versuch
|
||||||
|
action:
|
||||||
|
start: Start
|
||||||
|
resume: Fortsetzen
|
||||||
|
badge:
|
||||||
|
two_devices: 2 Telefone
|
||||||
|
same_or_unknown: gleiches Gerät / n. v.
|
||||||
clubs:
|
clubs:
|
||||||
index:
|
index:
|
||||||
title: Vereine & Teams
|
title: Vereine & Teams
|
||||||
@@ -220,6 +261,10 @@ de:
|
|||||||
sport: Sportart
|
sport: Sportart
|
||||||
matches_and_details: Spiele & Details
|
matches_and_details: Spiele & Details
|
||||||
replay: Replays
|
replay: Replays
|
||||||
|
concurrency_title: Versuche einer zweiten Direktübertragung
|
||||||
|
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief.
|
||||||
|
concurrency_none: Keine Versuche für diesen Verein erfasst.
|
||||||
|
concurrency_all: Alle Konto-Missbräuche ansehen
|
||||||
comped:
|
comped:
|
||||||
title: Kostenloses Abonnement
|
title: Kostenloses Abonnement
|
||||||
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
|
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
|
||||||
@@ -292,6 +337,8 @@ de:
|
|||||||
devices_title: Geräte
|
devices_title: Geräte
|
||||||
events_title: Ereignisse
|
events_title: Ereignisse
|
||||||
events_none: Keine Ereignisse erfasst.
|
events_none: Keine Ereignisse erfasst.
|
||||||
|
concurrency_title: Versuche einer zweiten Direktübertragung
|
||||||
|
concurrency_lead: Dieses Konto hat versucht, eine weitere Direktübertragung zu starten, während diese bereits lief (oder wurde von einer anderen Sitzung desselben Kontos blockiert).
|
||||||
status_label: "Status:"
|
status_label: "Status:"
|
||||||
stop_button: Sitzung beenden
|
stop_button: Sitzung beenden
|
||||||
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
|
stop_confirm: "Übertragung beenden? Der RTMP-Pfad wird entfernt und der Status wechselt zu beendet."
|
||||||
@@ -371,6 +418,10 @@ de:
|
|||||||
layer: Ebene
|
layer: Ebene
|
||||||
layer_move: Mausbewegungen
|
layer_move: Mausbewegungen
|
||||||
layer_click: Klicks
|
layer_click: Klicks
|
||||||
|
devices:
|
||||||
|
mobile: Mobil
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Desktop
|
||||||
index:
|
index:
|
||||||
title: Website-Analytics
|
title: Website-Analytics
|
||||||
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
|
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
|
||||||
@@ -391,14 +442,24 @@ de:
|
|||||||
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
scroll_hint: "Durchschnitt %{avg}% · Maximum %{max}%"
|
||||||
heatmap_moves: Mausbewegungs-Karte
|
heatmap_moves: Mausbewegungs-Karte
|
||||||
heatmap_clicks: Klickkarte
|
heatmap_clicks: Klickkarte
|
||||||
heatmap_hint: Farbiges Overlay auf dem Seiten-Screenshot. Grün = wenig, Rot = viel.
|
heatmap_hint: Farbiges Overlay auf dem %{device}-Screenshot. Grün = wenig, Rot = viel. Daten werden nicht zwischen Geräten gemischt.
|
||||||
|
device_tabs_label: Gerät
|
||||||
preview_title: Seiten-Screenshot
|
preview_title: Seiten-Screenshot
|
||||||
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
|
preview_unavailable: Vorschau für Pfade mit Platzhalter nicht verfügbar (z. B. /clubs/:id).
|
||||||
snapshot_missing: Noch kein Screenshot. Seite mit Analytics-Einwilligung (Desktop) besuchen und erneut versuchen.
|
snapshot_missing: Noch kein Screenshot für %{device}. Seite auf diesem Gerät mit Analytics-Einwilligung besuchen und erneut versuchen.
|
||||||
snapshot_meta: "Screenshot %{device} · %{at}"
|
snapshot_meta: "Screenshot %{device} · %{at}"
|
||||||
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
no_points: Keine aggregierten Punkte für diese Ebene im Zeitraum.
|
||||||
heatmap_title: Klickkarte
|
heatmap_title: Klickkarte
|
||||||
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
no_clicks: Keine aggregierten Klicks für diese Seite im Zeitraum.
|
||||||
|
preview:
|
||||||
|
title: Vorschau ohne Tracking
|
||||||
|
lead: Öffentliche Website durchsuchen ohne Pageviews, Klicks, Bewegungen oder Heatmap-Screenshots zu speichern. Für Produktionstests.
|
||||||
|
active: Staff-Vorschau in diesem Browser aktiv (7 Tage oder bis Deaktivierung).
|
||||||
|
enable: Staff-Vorschau aktivieren
|
||||||
|
disable: Vorschau deaktivieren
|
||||||
|
open_site: Website öffnen
|
||||||
|
enabled: Staff-Vorschau aktiviert. Dieser Browser speichert keine Analytics.
|
||||||
|
disabled: Staff-Vorschau deaktiviert.
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ en:
|
|||||||
billing: Billing
|
billing: Billing
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_concurrency: Account abuse
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costs
|
||||||
stream_nodes: Stream nodes
|
stream_nodes: Stream nodes
|
||||||
password: Password
|
password: Password
|
||||||
logout: Log out
|
logout: Log out
|
||||||
@@ -50,6 +52,9 @@ en:
|
|||||||
announcement_created: Notice saved
|
announcement_created: Notice saved
|
||||||
announcement_updated: Notice updated
|
announcement_updated: Notice updated
|
||||||
announcement_destroyed: Notice deleted
|
announcement_destroyed: Notice deleted
|
||||||
|
cost_entry_created: Cost entry saved.
|
||||||
|
cost_entry_updated: Cost entry updated.
|
||||||
|
cost_entry_destroyed: Cost entry deleted.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Yes"
|
yes: "Yes"
|
||||||
@@ -99,6 +104,12 @@ en:
|
|||||||
warnings: "%{count} warning(s)"
|
warnings: "%{count} warning(s)"
|
||||||
dashboard_link: Ops dashboard
|
dashboard_link: Ops dashboard
|
||||||
none_html: "No open incidents. %{link}"
|
none_html: "No open incidents. %{link}"
|
||||||
|
concurrency_panel:
|
||||||
|
title: Account abuse (second live)
|
||||||
|
count: "%{count} attempt(s) in the last 30 days"
|
||||||
|
view_all: View all
|
||||||
|
none: No attempts recorded.
|
||||||
|
none_html: "No second-live attempts from the same account. %{link}"
|
||||||
disk:
|
disk:
|
||||||
system_title: System disk
|
system_title: System disk
|
||||||
free_label: "Free: %{free} (%{percent}% used)"
|
free_label: "Free: %{free} (%{percent}% used)"
|
||||||
@@ -189,6 +200,36 @@ en:
|
|||||||
table:
|
table:
|
||||||
resolved_at: Resolved
|
resolved_at: Resolved
|
||||||
none: No incidents resolved recently.
|
none: No incidents resolved recently.
|
||||||
|
stream_concurrency:
|
||||||
|
index:
|
||||||
|
title: Account abuse
|
||||||
|
lead: Attempts to start a second live with the same account while another was already running (connecting, live, reconnecting or paused).
|
||||||
|
results: "Showing %{shown} of %{total}"
|
||||||
|
none: No attempts recorded.
|
||||||
|
kpi:
|
||||||
|
lookback: Last 30 days
|
||||||
|
lookback_sub: blocked attempts
|
||||||
|
two_devices: Two devices
|
||||||
|
two_devices_sub: different phone models
|
||||||
|
filters:
|
||||||
|
q: Search
|
||||||
|
q_placeholder: Email, club, match…
|
||||||
|
two_devices: Filter
|
||||||
|
two_devices_hint: Only attempts from different devices
|
||||||
|
apply: Filter
|
||||||
|
reset: Reset
|
||||||
|
table:
|
||||||
|
when: When
|
||||||
|
account: Account
|
||||||
|
club: Club
|
||||||
|
occupying: Live already running
|
||||||
|
attempted: Blocked attempt
|
||||||
|
action:
|
||||||
|
start: start
|
||||||
|
resume: resume
|
||||||
|
badge:
|
||||||
|
two_devices: 2 phones
|
||||||
|
same_or_unknown: same device / n.a.
|
||||||
clubs:
|
clubs:
|
||||||
index:
|
index:
|
||||||
title: Clubs & teams
|
title: Clubs & teams
|
||||||
@@ -220,6 +261,10 @@ en:
|
|||||||
sport: Sport
|
sport: Sport
|
||||||
matches_and_details: Matches & details
|
matches_and_details: Matches & details
|
||||||
replay: Replays
|
replay: Replays
|
||||||
|
concurrency_title: Second-live attempts
|
||||||
|
concurrency_lead: Same account tried to start another live while one was already running.
|
||||||
|
concurrency_none: No attempts recorded for this club.
|
||||||
|
concurrency_all: View all account abuse
|
||||||
comped:
|
comped:
|
||||||
title: Complimentary subscription
|
title: Complimentary subscription
|
||||||
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
|
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
|
||||||
@@ -292,6 +337,8 @@ en:
|
|||||||
devices_title: Devices
|
devices_title: Devices
|
||||||
events_title: Events
|
events_title: Events
|
||||||
events_none: No events recorded.
|
events_none: No events recorded.
|
||||||
|
concurrency_title: Second-live attempts
|
||||||
|
concurrency_lead: This account tried to start another live while this one was already running (or was blocked by another session of the same account).
|
||||||
status_label: "Status:"
|
status_label: "Status:"
|
||||||
stop_button: End session
|
stop_button: End session
|
||||||
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
|
stop_confirm: "End the broadcast? The RTMP path will be removed and the status will move to ended."
|
||||||
@@ -371,6 +418,10 @@ en:
|
|||||||
layer: Layer
|
layer: Layer
|
||||||
layer_move: Mouse moves
|
layer_move: Mouse moves
|
||||||
layer_click: Clicks
|
layer_click: Clicks
|
||||||
|
devices:
|
||||||
|
mobile: Mobile
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Desktop
|
||||||
index:
|
index:
|
||||||
title: Site analytics
|
title: Site analytics
|
||||||
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
|
lead: Aggregated first-party move/click heatmaps and scroll depth, only with analytics consent. No personal data.
|
||||||
@@ -391,14 +442,84 @@ en:
|
|||||||
scroll_hint: "Average %{avg}% · max %{max}%"
|
scroll_hint: "Average %{avg}% · max %{max}%"
|
||||||
heatmap_moves: Mouse-move map
|
heatmap_moves: Mouse-move map
|
||||||
heatmap_clicks: Click map
|
heatmap_clicks: Click map
|
||||||
heatmap_hint: Colored overlay on the page screenshot. Green = low, red = high.
|
heatmap_hint: Colored overlay on the %{device} screenshot. Green = low, red = high. Data is never mixed across devices.
|
||||||
|
device_tabs_label: Device
|
||||||
preview_title: Page screenshot
|
preview_title: Page screenshot
|
||||||
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
|
preview_unavailable: Preview unavailable for paths with placeholders (e.g. /clubs/:id).
|
||||||
snapshot_missing: No screenshot yet. Visit the page on the site with analytics consent (desktop), then retry in a few seconds.
|
snapshot_missing: No screenshot for %{device} yet. Visit the page on that device with analytics consent, then retry in a few seconds.
|
||||||
snapshot_meta: "Screenshot %{device} · captured %{at}"
|
snapshot_meta: "Screenshot %{device} · captured %{at}"
|
||||||
no_points: No aggregated points for this layer in the period.
|
no_points: No aggregated points for this layer in the period.
|
||||||
heatmap_title: Click map
|
heatmap_title: Click map
|
||||||
no_clicks: No aggregated clicks for this page in the period.
|
no_clicks: No aggregated clicks for this page in the period.
|
||||||
|
preview:
|
||||||
|
title: Preview without tracking
|
||||||
|
lead: Browse the public site without recording pageviews, clicks, moves or heatmap screenshots. Useful for production smoke tests.
|
||||||
|
active: Staff preview is active in this browser (7 days or until disabled).
|
||||||
|
enable: Enable staff preview
|
||||||
|
disable: Disable preview
|
||||||
|
open_site: Open site
|
||||||
|
enabled: Staff preview enabled. This browser will not record analytics.
|
||||||
|
disabled: Staff preview disabled.
|
||||||
|
|
||||||
|
costs:
|
||||||
|
index:
|
||||||
|
title: Cost analysis
|
||||||
|
lead: Enter monthly platform costs and compare cost, usage and revenue. KPIs recalculate automatically when you edit entries.
|
||||||
|
month: Month
|
||||||
|
apply: Apply
|
||||||
|
entries_title: Cost entries for the month
|
||||||
|
entries_lead: Add multiple line items (e.g. servers, storage, bandwidth). Month total is the sum of entries.
|
||||||
|
add_entry: Add entry
|
||||||
|
no_entries: No cost entries for this month. Add platform cost to see KPIs.
|
||||||
|
clubs_title: Allocated cost per club
|
||||||
|
clubs_lead: Infrastructure cost is split proportionally by streamed hours in the month.
|
||||||
|
no_clubs: No ended streams in this month.
|
||||||
|
trends_title: Trends (last 12 months)
|
||||||
|
kpi:
|
||||||
|
platform_cost: Platform cost
|
||||||
|
revenue: Revenue collected
|
||||||
|
margin: Margin
|
||||||
|
margin_pct: Margin %
|
||||||
|
hours: Streamed hours
|
||||||
|
sessions: Streams
|
||||||
|
clubs_active: Active clubs
|
||||||
|
storage: Replay storage
|
||||||
|
cost_per_hour: Cost / stream hour
|
||||||
|
cost_per_session: Cost / stream
|
||||||
|
cost_per_club: Cost / active club
|
||||||
|
revenue_per_hour: Revenue / stream hour
|
||||||
|
cost_per_gb: Cost / GB storage
|
||||||
|
storage_note: Current cumulative storage (not monthly).
|
||||||
|
charts:
|
||||||
|
cost_revenue: Cost vs revenue
|
||||||
|
cost_per_hour: Cost per streamed hour
|
||||||
|
hours: Streamed hours
|
||||||
|
table:
|
||||||
|
label: Item
|
||||||
|
amount: Amount
|
||||||
|
notes: Notes
|
||||||
|
club: Club
|
||||||
|
plan: Plan
|
||||||
|
hours: Hours
|
||||||
|
hours_share: Hour share
|
||||||
|
allocated_cost: Allocated cost
|
||||||
|
revenue: Revenue
|
||||||
|
margin: Margin
|
||||||
|
cost_per_hour: €/hour
|
||||||
|
cost_per_session: €/stream
|
||||||
|
storage: Storage
|
||||||
|
entries:
|
||||||
|
new_title: New cost entry
|
||||||
|
edit_title: Edit cost entry
|
||||||
|
month: Month
|
||||||
|
label: Description
|
||||||
|
label_hint: E.g. Hetzner, stream nodes, S3, egress.
|
||||||
|
amount: Amount (€)
|
||||||
|
notes: Notes
|
||||||
|
save: Save
|
||||||
|
cancel: Cancel
|
||||||
|
delete: Delete
|
||||||
|
delete_confirm: Delete this cost entry?
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ es:
|
|||||||
billing: Facturación
|
billing: Facturación
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
|
stream_concurrency: Abuso de cuenta
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costes
|
||||||
stream_nodes: Nodos stream
|
stream_nodes: Nodos stream
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
logout: Salir
|
logout: Salir
|
||||||
@@ -50,6 +52,9 @@ es:
|
|||||||
announcement_created: Aviso guardado
|
announcement_created: Aviso guardado
|
||||||
announcement_updated: Aviso actualizado
|
announcement_updated: Aviso actualizado
|
||||||
announcement_destroyed: Aviso eliminado
|
announcement_destroyed: Aviso eliminado
|
||||||
|
cost_entry_created: Partida de coste guardada.
|
||||||
|
cost_entry_updated: Partida de coste actualizada.
|
||||||
|
cost_entry_destroyed: Partida de coste eliminada.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Sí"
|
yes: "Sí"
|
||||||
@@ -99,6 +104,12 @@ es:
|
|||||||
warnings: "%{count} aviso(s)"
|
warnings: "%{count} aviso(s)"
|
||||||
dashboard_link: Panel de Ops
|
dashboard_link: Panel de Ops
|
||||||
none_html: "No hay incidencias abiertas. %{link}"
|
none_html: "No hay incidencias abiertas. %{link}"
|
||||||
|
concurrency_panel:
|
||||||
|
title: Abuso de cuenta (segundo directo)
|
||||||
|
count: "%{count} intento(s) en los últimos 30 días"
|
||||||
|
view_all: Ver todos
|
||||||
|
none: No hay intentos registrados.
|
||||||
|
none_html: "Ningún intento de segundo directo con la misma cuenta. %{link}"
|
||||||
disk:
|
disk:
|
||||||
system_title: Disco del sistema
|
system_title: Disco del sistema
|
||||||
free_label: "Libre: %{free} (%{percent}% usado)"
|
free_label: "Libre: %{free} (%{percent}% usado)"
|
||||||
@@ -189,6 +200,36 @@ es:
|
|||||||
table:
|
table:
|
||||||
resolved_at: Resuelta
|
resolved_at: Resuelta
|
||||||
none: No se han resuelto incidencias recientemente.
|
none: No se han resuelto incidencias recientemente.
|
||||||
|
stream_concurrency:
|
||||||
|
index:
|
||||||
|
title: Abuso de cuenta
|
||||||
|
lead: Intentos de iniciar un segundo directo con la misma cuenta mientras ya había otro en curso (connecting, live, reconnecting o en pausa).
|
||||||
|
results: "Mostrando %{shown} de %{total}"
|
||||||
|
none: No hay intentos registrados.
|
||||||
|
kpi:
|
||||||
|
lookback: Últimos 30 días
|
||||||
|
lookback_sub: intentos bloqueados
|
||||||
|
two_devices: Dos dispositivos
|
||||||
|
two_devices_sub: modelos de teléfono distintos
|
||||||
|
filters:
|
||||||
|
q: Buscar
|
||||||
|
q_placeholder: Email, club, partido…
|
||||||
|
two_devices: Filtro
|
||||||
|
two_devices_hint: Solo intentos desde dispositivos distintos
|
||||||
|
apply: Filtrar
|
||||||
|
reset: Restablecer
|
||||||
|
table:
|
||||||
|
when: Cuándo
|
||||||
|
account: Cuenta
|
||||||
|
club: Club
|
||||||
|
occupying: Directo ya en curso
|
||||||
|
attempted: Intento bloqueado
|
||||||
|
action:
|
||||||
|
start: inicio
|
||||||
|
resume: reanudación
|
||||||
|
badge:
|
||||||
|
two_devices: 2 teléfonos
|
||||||
|
same_or_unknown: mismo dispositivo / n. d.
|
||||||
clubs:
|
clubs:
|
||||||
index:
|
index:
|
||||||
title: Clubes y equipos
|
title: Clubes y equipos
|
||||||
@@ -220,6 +261,10 @@ es:
|
|||||||
sport: Deporte
|
sport: Deporte
|
||||||
matches_and_details: Partidos y detalles
|
matches_and_details: Partidos y detalles
|
||||||
replay: Repeticiones
|
replay: Repeticiones
|
||||||
|
concurrency_title: Intentos de segundo directo
|
||||||
|
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso.
|
||||||
|
concurrency_none: No hay intentos registrados para este club.
|
||||||
|
concurrency_all: Ver todos los abusos de cuenta
|
||||||
comped:
|
comped:
|
||||||
title: Suscripción de cortesía
|
title: Suscripción de cortesía
|
||||||
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
|
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
|
||||||
@@ -292,6 +337,8 @@ es:
|
|||||||
devices_title: Dispositivos
|
devices_title: Dispositivos
|
||||||
events_title: Eventos
|
events_title: Eventos
|
||||||
events_none: No hay eventos registrados.
|
events_none: No hay eventos registrados.
|
||||||
|
concurrency_title: Intentos de segundo directo
|
||||||
|
concurrency_lead: Esta cuenta intentó iniciar otro directo mientras este ya estaba en curso (o fue bloqueada por otra sesión de la misma cuenta).
|
||||||
status_label: "Estado:"
|
status_label: "Estado:"
|
||||||
stop_button: Finalizar sesión
|
stop_button: Finalizar sesión
|
||||||
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
|
stop_confirm: "¿Finalizar la emisión? La ruta RTMP se eliminará y el estado pasará a finalizada."
|
||||||
@@ -371,6 +418,10 @@ es:
|
|||||||
layer: Capa
|
layer: Capa
|
||||||
layer_move: Movimientos del ratón
|
layer_move: Movimientos del ratón
|
||||||
layer_click: Clics
|
layer_click: Clics
|
||||||
|
devices:
|
||||||
|
mobile: Móvil
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Escritorio
|
||||||
index:
|
index:
|
||||||
title: Analytics del sitio
|
title: Analytics del sitio
|
||||||
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
|
lead: Heatmaps de movimientos/clics y scroll agregados (first-party), solo con consentimiento estadístico. Sin datos personales.
|
||||||
@@ -391,14 +442,24 @@ es:
|
|||||||
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
scroll_hint: "Media %{avg}% · máximo %{max}%"
|
||||||
heatmap_moves: Mapa de movimientos
|
heatmap_moves: Mapa de movimientos
|
||||||
heatmap_clicks: Mapa de clics
|
heatmap_clicks: Mapa de clics
|
||||||
heatmap_hint: Superposición de color sobre la captura de la página. Verde = poco, rojo = mucho.
|
heatmap_hint: Superposición de color sobre la captura %{device}. Verde = poco, rojo = mucho. Los datos no se mezclan entre dispositivos.
|
||||||
|
device_tabs_label: Dispositivo
|
||||||
preview_title: Captura de página
|
preview_title: Captura de página
|
||||||
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
|
preview_unavailable: Vista previa no disponible para rutas con placeholder (p. ej. /clubs/:id).
|
||||||
snapshot_missing: Aún no hay captura. Visita la página con consentimiento analytics (escritorio) y vuelve a intentarlo.
|
snapshot_missing: Aún no hay captura para %{device}. Visita la página con ese dispositivo y consentimiento analytics, y vuelve a intentarlo.
|
||||||
snapshot_meta: "Captura %{device} · %{at}"
|
snapshot_meta: "Captura %{device} · %{at}"
|
||||||
no_points: No hay puntos agregados para esta capa en el periodo.
|
no_points: No hay puntos agregados para esta capa en el periodo.
|
||||||
heatmap_title: Mapa de clics
|
heatmap_title: Mapa de clics
|
||||||
no_clicks: No hay clics agregados para esta página en el periodo.
|
no_clicks: No hay clics agregados para esta página en el periodo.
|
||||||
|
preview:
|
||||||
|
title: Vista previa sin seguimiento
|
||||||
|
lead: Navega el sitio público sin registrar pageviews, clics, movimientos ni capturas heatmap. Útil para pruebas en producción.
|
||||||
|
active: Vista previa staff activa en este navegador (7 días o hasta desactivar).
|
||||||
|
enable: Activar vista previa staff
|
||||||
|
disable: Desactivar vista previa
|
||||||
|
open_site: Abrir sitio
|
||||||
|
enabled: Vista previa staff activada. Este navegador no registrará analytics.
|
||||||
|
disabled: Vista previa staff desactivada.
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ fr:
|
|||||||
billing: Facturation
|
billing: Facturation
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
|
stream_concurrency: Abus de compte
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Coûts
|
||||||
stream_nodes: Nœuds stream
|
stream_nodes: Nœuds stream
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
@@ -50,6 +52,9 @@ fr:
|
|||||||
announcement_created: Alerte enregistrée
|
announcement_created: Alerte enregistrée
|
||||||
announcement_updated: Alerte mise à jour
|
announcement_updated: Alerte mise à jour
|
||||||
announcement_destroyed: Alerte supprimée
|
announcement_destroyed: Alerte supprimée
|
||||||
|
cost_entry_created: Poste de coût enregistré.
|
||||||
|
cost_entry_updated: Poste de coût mis à jour.
|
||||||
|
cost_entry_destroyed: Poste de coût supprimé.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Oui"
|
yes: "Oui"
|
||||||
@@ -99,6 +104,12 @@ fr:
|
|||||||
warnings: "%{count} avertissement(s)"
|
warnings: "%{count} avertissement(s)"
|
||||||
dashboard_link: Tableau de bord Ops
|
dashboard_link: Tableau de bord Ops
|
||||||
none_html: "Aucun incident ouvert. %{link}"
|
none_html: "Aucun incident ouvert. %{link}"
|
||||||
|
concurrency_panel:
|
||||||
|
title: Abus de compte (deuxième direct)
|
||||||
|
count: "%{count} tentative(s) sur les 30 derniers jours"
|
||||||
|
view_all: Voir tout
|
||||||
|
none: Aucune tentative enregistrée.
|
||||||
|
none_html: "Aucune tentative de second direct avec le même compte. %{link}"
|
||||||
disk:
|
disk:
|
||||||
system_title: Disque système
|
system_title: Disque système
|
||||||
free_label: "Libre : %{free} (%{percent}% utilisé)"
|
free_label: "Libre : %{free} (%{percent}% utilisé)"
|
||||||
@@ -189,6 +200,36 @@ fr:
|
|||||||
table:
|
table:
|
||||||
resolved_at: Résolu
|
resolved_at: Résolu
|
||||||
none: Aucun incident résolu récemment.
|
none: Aucun incident résolu récemment.
|
||||||
|
stream_concurrency:
|
||||||
|
index:
|
||||||
|
title: Abus de compte
|
||||||
|
lead: Tentatives de démarrer un second direct avec le même compte alors qu’un autre était déjà en cours (connecting, live, reconnecting ou en pause).
|
||||||
|
results: "%{shown} sur %{total} affichés"
|
||||||
|
none: Aucune tentative enregistrée.
|
||||||
|
kpi:
|
||||||
|
lookback: 30 derniers jours
|
||||||
|
lookback_sub: tentatives bloquées
|
||||||
|
two_devices: Deux appareils
|
||||||
|
two_devices_sub: modèles de téléphone différents
|
||||||
|
filters:
|
||||||
|
q: Rechercher
|
||||||
|
q_placeholder: E-mail, club, match…
|
||||||
|
two_devices: Filtre
|
||||||
|
two_devices_hint: Uniquement les tentatives depuis des appareils différents
|
||||||
|
apply: Filtrer
|
||||||
|
reset: Réinitialiser
|
||||||
|
table:
|
||||||
|
when: Quand
|
||||||
|
account: Compte
|
||||||
|
club: Club
|
||||||
|
occupying: Direct déjà en cours
|
||||||
|
attempted: Tentative bloquée
|
||||||
|
action:
|
||||||
|
start: démarrage
|
||||||
|
resume: reprise
|
||||||
|
badge:
|
||||||
|
two_devices: 2 téléphones
|
||||||
|
same_or_unknown: même appareil / n. d.
|
||||||
clubs:
|
clubs:
|
||||||
index:
|
index:
|
||||||
title: Clubs et équipes
|
title: Clubs et équipes
|
||||||
@@ -220,6 +261,10 @@ fr:
|
|||||||
sport: Sport
|
sport: Sport
|
||||||
matches_and_details: Matchs et détails
|
matches_and_details: Matchs et détails
|
||||||
replay: Replays
|
replay: Replays
|
||||||
|
concurrency_title: Tentatives de second direct
|
||||||
|
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors qu’un était déjà en cours.
|
||||||
|
concurrency_none: Aucune tentative enregistrée pour ce club.
|
||||||
|
concurrency_all: Voir tous les abus de compte
|
||||||
comped:
|
comped:
|
||||||
title: Abonnement offert
|
title: Abonnement offert
|
||||||
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
|
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
|
||||||
@@ -292,6 +337,8 @@ fr:
|
|||||||
devices_title: Appareils
|
devices_title: Appareils
|
||||||
events_title: Événements
|
events_title: Événements
|
||||||
events_none: Aucun événement enregistré.
|
events_none: Aucun événement enregistré.
|
||||||
|
concurrency_title: Tentatives de second direct
|
||||||
|
concurrency_lead: Ce compte a tenté de démarrer un autre direct alors que celui-ci était déjà en cours (ou a été bloqué par une autre session du même compte).
|
||||||
status_label: "Statut :"
|
status_label: "Statut :"
|
||||||
stop_button: Terminer la session
|
stop_button: Terminer la session
|
||||||
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
|
stop_confirm: "Terminer la diffusion ? Le chemin RTMP sera supprimé et le statut passera à terminé."
|
||||||
@@ -371,6 +418,10 @@ fr:
|
|||||||
layer: Couche
|
layer: Couche
|
||||||
layer_move: Mouvements souris
|
layer_move: Mouvements souris
|
||||||
layer_click: Clics
|
layer_click: Clics
|
||||||
|
devices:
|
||||||
|
mobile: Mobile
|
||||||
|
tablet: Tablette
|
||||||
|
desktop: Ordinateur
|
||||||
index:
|
index:
|
||||||
title: Analytics du site
|
title: Analytics du site
|
||||||
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
|
lead: Heatmaps mouvements/clics et scroll agrégés (first-party), uniquement avec consentement statistiques. Aucune donnée personnelle.
|
||||||
@@ -391,14 +442,24 @@ fr:
|
|||||||
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
scroll_hint: "Moyenne %{avg}% · maximum %{max}%"
|
||||||
heatmap_moves: Carte des mouvements
|
heatmap_moves: Carte des mouvements
|
||||||
heatmap_clicks: Carte des clics
|
heatmap_clicks: Carte des clics
|
||||||
heatmap_hint: Superposition colorée sur la capture d’écran de la page. Vert = faible, rouge = fort.
|
heatmap_hint: Superposition colorée sur la capture %{device}. Vert = faible, rouge = fort. Les données ne sont pas mélangées entre appareils.
|
||||||
|
device_tabs_label: Appareil
|
||||||
preview_title: Capture de page
|
preview_title: Capture de page
|
||||||
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
|
preview_unavailable: Aperçu indisponible pour les chemins avec placeholder (ex. /clubs/:id).
|
||||||
snapshot_missing: Pas encore de capture. Visitez la page avec consentement analytics (desktop), puis réessayez.
|
snapshot_missing: Pas encore de capture pour %{device}. Visitez la page sur cet appareil avec consentement analytics, puis réessayez.
|
||||||
snapshot_meta: "Capture %{device} · %{at}"
|
snapshot_meta: "Capture %{device} · %{at}"
|
||||||
no_points: Aucun point agrégé pour cette couche sur la période.
|
no_points: Aucun point agrégé pour cette couche sur la période.
|
||||||
heatmap_title: Carte des clics
|
heatmap_title: Carte des clics
|
||||||
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
no_clicks: Aucun clic agrégé pour cette page sur la période.
|
||||||
|
preview:
|
||||||
|
title: Aperçu sans suivi
|
||||||
|
lead: Parcourez le site public sans enregistrer pages vues, clics, mouvements ni captures heatmap. Utile pour tester en production.
|
||||||
|
active: Aperçu staff actif sur ce navigateur (7 jours ou jusqu'à désactivation).
|
||||||
|
enable: Activer l'aperçu staff
|
||||||
|
disable: Désactiver l'aperçu
|
||||||
|
open_site: Ouvrir le site
|
||||||
|
enabled: Aperçu staff activé. Ce navigateur ne enregistrera pas d'analytics.
|
||||||
|
disabled: Aperçu staff désactivé.
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ it:
|
|||||||
billing: Fatturazione
|
billing: Fatturazione
|
||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
|
stream_concurrency: Abusi account
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costi
|
||||||
stream_nodes: Nodi stream
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
@@ -54,6 +56,9 @@ it:
|
|||||||
announcement_created: Avviso salvato
|
announcement_created: Avviso salvato
|
||||||
announcement_updated: Avviso aggiornato
|
announcement_updated: Avviso aggiornato
|
||||||
announcement_destroyed: Avviso eliminato
|
announcement_destroyed: Avviso eliminato
|
||||||
|
cost_entry_created: Voce di costo registrata.
|
||||||
|
cost_entry_updated: Voce di costo aggiornata.
|
||||||
|
cost_entry_destroyed: Voce di costo eliminata.
|
||||||
common:
|
common:
|
||||||
free_plan: Free
|
free_plan: Free
|
||||||
yes: "Sì"
|
yes: "Sì"
|
||||||
@@ -103,6 +108,12 @@ it:
|
|||||||
warnings: "%{count} warning"
|
warnings: "%{count} warning"
|
||||||
dashboard_link: Dashboard Ops
|
dashboard_link: Dashboard Ops
|
||||||
none_html: "Nessun incidente aperto. %{link}"
|
none_html: "Nessun incidente aperto. %{link}"
|
||||||
|
concurrency_panel:
|
||||||
|
title: Abusi account (seconda diretta)
|
||||||
|
count: "%{count} tentativo/i negli ultimi 30 giorni"
|
||||||
|
view_all: Vedi tutti
|
||||||
|
none: Nessun tentativo registrato.
|
||||||
|
none_html: "Nessun tentativo di seconda diretta dallo stesso account. %{link}"
|
||||||
disk:
|
disk:
|
||||||
system_title: Disco sistema
|
system_title: Disco sistema
|
||||||
free_label: "Libero: %{free} (%{percent}% usato)"
|
free_label: "Libero: %{free} (%{percent}% usato)"
|
||||||
@@ -193,6 +204,36 @@ it:
|
|||||||
table:
|
table:
|
||||||
resolved_at: Risolto
|
resolved_at: Risolto
|
||||||
none: Nessun incidente risolto di recente.
|
none: Nessun incidente risolto di recente.
|
||||||
|
stream_concurrency:
|
||||||
|
index:
|
||||||
|
title: Abusi account
|
||||||
|
lead: Tentativi di avviare una seconda diretta con lo stesso account mentre un’altra era già in corso (connecting, live, reconnecting o in pausa).
|
||||||
|
results: "Mostrate %{shown} di %{total}"
|
||||||
|
none: Nessun tentativo registrato.
|
||||||
|
kpi:
|
||||||
|
lookback: Ultimi 30 giorni
|
||||||
|
lookback_sub: tentativi bloccati
|
||||||
|
two_devices: Due dispositivi
|
||||||
|
two_devices_sub: modelli telefono diversi
|
||||||
|
filters:
|
||||||
|
q: Cerca
|
||||||
|
q_placeholder: Email, società, partita…
|
||||||
|
two_devices: Filtro
|
||||||
|
two_devices_hint: Solo tentativi da dispositivi diversi
|
||||||
|
apply: Filtra
|
||||||
|
reset: Reset
|
||||||
|
table:
|
||||||
|
when: Quando
|
||||||
|
account: Account
|
||||||
|
club: Società
|
||||||
|
occupying: Diretta già in corso
|
||||||
|
attempted: Tentativo bloccato
|
||||||
|
action:
|
||||||
|
start: avvio
|
||||||
|
resume: ripresa
|
||||||
|
badge:
|
||||||
|
two_devices: 2 telefoni
|
||||||
|
same_or_unknown: stesso device / n.d.
|
||||||
clubs:
|
clubs:
|
||||||
index:
|
index:
|
||||||
title: Società e squadre
|
title: Società e squadre
|
||||||
@@ -225,6 +266,10 @@ it:
|
|||||||
sport: Sport
|
sport: Sport
|
||||||
matches_and_details: Partite e dettagli
|
matches_and_details: Partite e dettagli
|
||||||
replay: Replay
|
replay: Replay
|
||||||
|
concurrency_title: Tentativi di seconda diretta
|
||||||
|
concurrency_lead: Stesso account che ha provato ad avviare un’altra diretta mentre ne era già in corso una.
|
||||||
|
concurrency_none: Nessun tentativo registrato per questa società.
|
||||||
|
concurrency_all: Vedi tutti gli abusi account
|
||||||
comped:
|
comped:
|
||||||
title: Abbonamento omaggio
|
title: Abbonamento omaggio
|
||||||
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
|
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
|
||||||
@@ -313,6 +358,8 @@ it:
|
|||||||
devices_title: Dispositivi
|
devices_title: Dispositivi
|
||||||
events_title: Eventi
|
events_title: Eventi
|
||||||
events_none: Nessun evento registrato.
|
events_none: Nessun evento registrato.
|
||||||
|
concurrency_title: Tentativi di seconda diretta
|
||||||
|
concurrency_lead: Questo account ha provato ad avviare un’altra diretta mentre questa era già in corso (o è stato bloccato da un’altra sessione dello stesso account).
|
||||||
status_label: "Stato:"
|
status_label: "Stato:"
|
||||||
stop_button: Termina sessione
|
stop_button: Termina sessione
|
||||||
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
|
stop_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended."
|
||||||
@@ -392,6 +439,10 @@ it:
|
|||||||
layer: Livello
|
layer: Livello
|
||||||
layer_move: Movimenti mouse
|
layer_move: Movimenti mouse
|
||||||
layer_click: Click
|
layer_click: Click
|
||||||
|
devices:
|
||||||
|
mobile: Mobile
|
||||||
|
tablet: Tablet
|
||||||
|
desktop: Desktop
|
||||||
index:
|
index:
|
||||||
title: Analytics sito
|
title: Analytics sito
|
||||||
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
|
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
|
||||||
@@ -412,14 +463,84 @@ it:
|
|||||||
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
scroll_hint: "Media %{avg}% · massimo %{max}%"
|
||||||
heatmap_moves: Mappa movimenti mouse
|
heatmap_moves: Mappa movimenti mouse
|
||||||
heatmap_clicks: Mappa click
|
heatmap_clicks: Mappa click
|
||||||
heatmap_hint: Overlay colorato sullo screenshot della pagina. Verde = poco, rosso = molto.
|
heatmap_hint: Overlay colorato sullo screenshot %{device}. Verde = poco, rosso = molto. I dati non vengono mischiati tra dispositivi.
|
||||||
|
device_tabs_label: Dispositivo
|
||||||
preview_title: Screenshot pagina
|
preview_title: Screenshot pagina
|
||||||
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
|
preview_unavailable: Anteprima non disponibile per path con placeholder (es. /clubs/:id).
|
||||||
snapshot_missing: Nessuno screenshot ancora. Visita la pagina sul sito con consenso analytics (desktop) e riprova tra qualche secondo.
|
snapshot_missing: Nessuno screenshot per %{device}. Visita la pagina sul sito con quel dispositivo e consenso analytics, poi riprova tra qualche secondo.
|
||||||
snapshot_meta: "Screenshot %{device} · catturato %{at}"
|
snapshot_meta: "Screenshot %{device} · catturato %{at}"
|
||||||
no_points: Nessun punto aggregato per questo livello nel periodo.
|
no_points: Nessun punto aggregato per questo livello nel periodo.
|
||||||
heatmap_title: Mappa click
|
heatmap_title: Mappa click
|
||||||
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
no_clicks: Nessun click aggregato per questa pagina nel periodo.
|
||||||
|
preview:
|
||||||
|
title: Anteprima senza tracciamento
|
||||||
|
lead: Visita il sito pubblico senza registrare pageview, click, movimenti o screenshot heatmap. Utile per test in produzione.
|
||||||
|
active: Modalità anteprima attiva su questo browser (7 giorni o fino a disattivazione).
|
||||||
|
enable: Attiva anteprima staff
|
||||||
|
disable: Disattiva anteprima
|
||||||
|
open_site: Apri sito
|
||||||
|
enabled: Anteprima staff attivata. Il sito non registrerà analytics su questo browser.
|
||||||
|
disabled: Anteprima staff disattivata.
|
||||||
|
|
||||||
|
costs:
|
||||||
|
index:
|
||||||
|
title: Analisi costi
|
||||||
|
lead: Inserisci i costi mensili della piattaforma e confronta costo, utilizzo e revenue. I KPI si ricalcolano automaticamente quando modifichi le voci.
|
||||||
|
month: Mese
|
||||||
|
apply: Applica
|
||||||
|
entries_title: Voci di costo del mese
|
||||||
|
entries_lead: Puoi aggiungere più voci (es. server, storage, banda). Il totale è la somma delle voci del mese.
|
||||||
|
add_entry: Aggiungi voce
|
||||||
|
no_entries: Nessuna voce di costo per questo mese. Aggiungi il costo della piattaforma per vedere i KPI.
|
||||||
|
clubs_title: Costo allocato per società
|
||||||
|
clubs_lead: Il costo infrastruttura è ripartito proporzionalmente alle ore trasmesse nel mese.
|
||||||
|
no_clubs: Nessuna diretta terminata in questo mese.
|
||||||
|
trends_title: Trend (ultimi 12 mesi)
|
||||||
|
kpi:
|
||||||
|
platform_cost: Costo piattaforma
|
||||||
|
revenue: Revenue incassata
|
||||||
|
margin: Margine
|
||||||
|
margin_pct: Margine %
|
||||||
|
hours: Ore trasmesse
|
||||||
|
sessions: Dirette
|
||||||
|
clubs_active: Società attive
|
||||||
|
storage: Storage replay
|
||||||
|
cost_per_hour: Costo / ora stream
|
||||||
|
cost_per_session: Costo / diretta
|
||||||
|
cost_per_club: Costo / società attiva
|
||||||
|
revenue_per_hour: Revenue / ora stream
|
||||||
|
cost_per_gb: Costo / GB storage
|
||||||
|
storage_note: Storage cumulativo attuale (non mensile).
|
||||||
|
charts:
|
||||||
|
cost_revenue: Costo vs revenue
|
||||||
|
cost_per_hour: Costo per ora trasmessa
|
||||||
|
hours: Ore trasmesse
|
||||||
|
table:
|
||||||
|
label: Voce
|
||||||
|
amount: Importo
|
||||||
|
notes: Note
|
||||||
|
club: Società
|
||||||
|
plan: Piano
|
||||||
|
hours: Ore
|
||||||
|
hours_share: Quota ore
|
||||||
|
allocated_cost: Costo allocato
|
||||||
|
revenue: Revenue
|
||||||
|
margin: Margine
|
||||||
|
cost_per_hour: €/ora
|
||||||
|
cost_per_session: €/diretta
|
||||||
|
storage: Storage
|
||||||
|
entries:
|
||||||
|
new_title: Nuova voce di costo
|
||||||
|
edit_title: Modifica voce di costo
|
||||||
|
month: Mese
|
||||||
|
label: Descrizione
|
||||||
|
label_hint: Es. Hetzner, nodi stream CPX, S3, banda egress.
|
||||||
|
amount: Importo (€)
|
||||||
|
notes: Note
|
||||||
|
save: Salva
|
||||||
|
cancel: Annulla
|
||||||
|
delete: Elimina
|
||||||
|
delete_confirm: Eliminare questa voce di costo?
|
||||||
|
|
||||||
billing:
|
billing:
|
||||||
index:
|
index:
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
de:
|
de:
|
||||||
|
api:
|
||||||
|
errors:
|
||||||
|
user_concurrent_stream: "Mit diesem Konto läuft bereits eine Direktübertragung. Beende sie, bevor du eine weitere startest."
|
||||||
password_policy:
|
password_policy:
|
||||||
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
|
hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen."
|
||||||
activerecord:
|
activerecord:
|
||||||
@@ -77,10 +80,10 @@ de:
|
|||||||
title: "Verein registrieren — Match Live TV"
|
title: "Verein registrieren — Match Live TV"
|
||||||
meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV.
|
meta_description: Erstelle deinen Sportverein und das erste Team auf Match Live TV.
|
||||||
heading: Dein Verein
|
heading: Dein Verein
|
||||||
lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, Serie C…)."
|
lead: "Registriere den Verein: du kannst später weitere Teams hinzufügen (U13, U15, erste Mannschaft…)."
|
||||||
section_club: Verein
|
section_club: Verein
|
||||||
name_label: Vereinsname / Club
|
name_label: Vereinsname / Club
|
||||||
name_placeholder: "z. B. Crazy Volley Rozzano"
|
name_placeholder: "z. B. Team MLTV"
|
||||||
section_first_team: Erstes Team
|
section_first_team: Erstes Team
|
||||||
default_first_team_name: Erstes Team
|
default_first_team_name: Erstes Team
|
||||||
first_team_name_label: Teamname
|
first_team_name_label: Teamname
|
||||||
@@ -159,7 +162,7 @@ de:
|
|||||||
heading: Neues Team
|
heading: Neues Team
|
||||||
club_label: "Verein:"
|
club_label: "Verein:"
|
||||||
name_label: Teamname
|
name_label: Teamname
|
||||||
name_placeholder: "z. B. Under 13, Serie C"
|
name_placeholder: "z. B. U15 männlich"
|
||||||
branding_legend: Branding-Überschreibung (optional)
|
branding_legend: Branding-Überschreibung (optional)
|
||||||
submit: Team hinzufügen
|
submit: Team hinzufügen
|
||||||
invite:
|
invite:
|
||||||
@@ -235,7 +238,7 @@ de:
|
|||||||
matches:
|
matches:
|
||||||
back_to_list: "← Spieleliste"
|
back_to_list: "← Spieleliste"
|
||||||
opponent_label: Gegner
|
opponent_label: Gegner
|
||||||
opponent_placeholder: "z. B. Volley Milano"
|
opponent_placeholder: "z. B. Team Guest"
|
||||||
location_label: "Ort (optional)"
|
location_label: "Ort (optional)"
|
||||||
location_placeholder: "Halle, Stadt"
|
location_placeholder: "Halle, Stadt"
|
||||||
datetime_label: Datum und Uhrzeit
|
datetime_label: Datum und Uhrzeit
|
||||||
@@ -361,7 +364,7 @@ de:
|
|||||||
legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt."
|
legend_hint: "Alle markierten Felder sind erforderlich, um einen Premium-Tarif zu abonnieren und Rechnungen auszustellen. Vereine benötigen eine USt-IdNr., Privatpersonen eine Steuernummer. Du brauchst entweder SDI oder eine zertifizierte E-Mail (PEC). Die Zahlungen werden weiterhin sicher von Stripe abgewickelt."
|
||||||
entity_type_label: "Art des Rechnungsempfängers *"
|
entity_type_label: "Art des Rechnungsempfängers *"
|
||||||
legal_name_label: "Firmenname / Vor- und Nachname *"
|
legal_name_label: "Firmenname / Vor- und Nachname *"
|
||||||
legal_name_placeholder: "z. B. ASD Tigers Volley"
|
legal_name_placeholder: "z. B. Team MLTV"
|
||||||
vat_number_label: "USt-IdNr. * (Verein)"
|
vat_number_label: "USt-IdNr. * (Verein)"
|
||||||
fiscal_code_label: "Steuernummer * (Privatperson)"
|
fiscal_code_label: "Steuernummer * (Privatperson)"
|
||||||
fiscal_code_placeholder: RSSMRA80A01H501U
|
fiscal_code_placeholder: RSSMRA80A01H501U
|
||||||
@@ -738,7 +741,7 @@ de:
|
|||||||
replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv"
|
replay_archive_link: "Vergangene Live-Übertragungen — Wiederholungsarchiv"
|
||||||
schedule_match_link: "Spiel planen"
|
schedule_match_link: "Spiel planen"
|
||||||
search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…"
|
search_placeholder_club: "Verein, Team, Gegner oder Ort suchen…"
|
||||||
search_placeholder_default: "Z. B. Crazy Volley, Serie D, Gegner…"
|
search_placeholder_default: "Z. B. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Team suchen"
|
search_aria_label: "Team suchen"
|
||||||
search_button: "Suchen"
|
search_button: "Suchen"
|
||||||
reset_link: "Zurücksetzen"
|
reset_link: "Zurücksetzen"
|
||||||
@@ -772,7 +775,7 @@ de:
|
|||||||
empty_hero_cta_features: "Erfahre, wie es funktioniert"
|
empty_hero_cta_features: "Erfahre, wie es funktioniert"
|
||||||
demo_aria_label: "Beispiel einer aktiven Live-Übertragung"
|
demo_aria_label: "Beispiel einer aktiven Live-Übertragung"
|
||||||
demo_label: "Beispiel — so sieht eine aktive Übertragung aus"
|
demo_label: "Beispiel — so sieht eine aktive Übertragung aus"
|
||||||
demo_meta: "PalaTigers · Match Live TV"
|
demo_meta: "Pala MLTV · Match Live TV"
|
||||||
demo_sets: "Satz 2 · Sätze gewonnen 1-0"
|
demo_sets: "Satz 2 · Sätze gewonnen 1-0"
|
||||||
show:
|
show:
|
||||||
back_to_all: "← Alle Live-Übertragungen"
|
back_to_all: "← Alle Live-Übertragungen"
|
||||||
@@ -805,7 +808,7 @@ de:
|
|||||||
back_to_live: "← Live-Übertragungen"
|
back_to_live: "← Live-Übertragungen"
|
||||||
title: "Vergangene Live-Übertragungen"
|
title: "Vergangene Live-Übertragungen"
|
||||||
hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele."
|
hint: "Öffentliche Wiederholungen von Sportvereinen — betrifft bereits übertragene Spiele."
|
||||||
search_placeholder: "Z. B. Tigers Volley, Gegner, Verein, Ort…"
|
search_placeholder: "Z. B. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Wiederholung suchen"
|
search_aria_label: "Wiederholung suchen"
|
||||||
search_button: "Suchen"
|
search_button: "Suchen"
|
||||||
reset_link: "Zurücksetzen"
|
reset_link: "Zurücksetzen"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
en:
|
en:
|
||||||
|
api:
|
||||||
|
errors:
|
||||||
|
user_concurrent_stream: "You already have a live stream running on this account. Stop it before starting another."
|
||||||
password_policy:
|
password_policy:
|
||||||
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
|
hint: "At least 8 characters, including 3 of: lowercase, uppercase, numbers and symbols."
|
||||||
activerecord:
|
activerecord:
|
||||||
@@ -82,10 +85,10 @@ en:
|
|||||||
title: "Register a club — Match Live TV"
|
title: "Register a club — Match Live TV"
|
||||||
meta_description: Create your sports club and its first team on Match Live TV.
|
meta_description: Create your sports club and its first team on Match Live TV.
|
||||||
heading: Your club
|
heading: Your club
|
||||||
lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, Serie C…)."
|
lead: "Register the club: you'll be able to add more teams (Under 13, Under 15, first team…)."
|
||||||
section_club: Club
|
section_club: Club
|
||||||
name_label: Club name
|
name_label: Club name
|
||||||
name_placeholder: "e.g. Crazy Volley Rozzano"
|
name_placeholder: "e.g. Team MLTV"
|
||||||
section_first_team: First team
|
section_first_team: First team
|
||||||
default_first_team_name: First team
|
default_first_team_name: First team
|
||||||
first_team_name_label: Team name
|
first_team_name_label: Team name
|
||||||
@@ -164,7 +167,7 @@ en:
|
|||||||
heading: New team
|
heading: New team
|
||||||
club_label: "Club:"
|
club_label: "Club:"
|
||||||
name_label: Team name
|
name_label: Team name
|
||||||
name_placeholder: "e.g. Under 13, Serie C"
|
name_placeholder: "e.g. U15 boys"
|
||||||
branding_legend: Branding override (optional)
|
branding_legend: Branding override (optional)
|
||||||
submit: Add team
|
submit: Add team
|
||||||
invite:
|
invite:
|
||||||
@@ -240,7 +243,7 @@ en:
|
|||||||
matches:
|
matches:
|
||||||
back_to_list: "← Match list"
|
back_to_list: "← Match list"
|
||||||
opponent_label: Opponent
|
opponent_label: Opponent
|
||||||
opponent_placeholder: "e.g. Volley Milano"
|
opponent_placeholder: "e.g. Team Guest"
|
||||||
location_label: "Location (optional)"
|
location_label: "Location (optional)"
|
||||||
location_placeholder: "Gym, city"
|
location_placeholder: "Gym, city"
|
||||||
datetime_label: Date and time
|
datetime_label: Date and time
|
||||||
@@ -366,7 +369,7 @@ en:
|
|||||||
legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe."
|
legend_hint: "All marked fields are required to subscribe to a premium plan and to issue invoices. Clubs need a VAT number; private individuals need a fiscal code. You need either SDI or certified email (PEC). Payments remain securely handled by Stripe."
|
||||||
entity_type_label: "Billing entity type *"
|
entity_type_label: "Billing entity type *"
|
||||||
legal_name_label: "Legal name / full name *"
|
legal_name_label: "Legal name / full name *"
|
||||||
legal_name_placeholder: "e.g. ASD Tigers Volley"
|
legal_name_placeholder: "e.g. Team MLTV"
|
||||||
vat_number_label: "VAT number * (club)"
|
vat_number_label: "VAT number * (club)"
|
||||||
fiscal_code_label: "Fiscal code * (private individual)"
|
fiscal_code_label: "Fiscal code * (private individual)"
|
||||||
fiscal_code_placeholder: RSSMRA80A01H501U
|
fiscal_code_placeholder: RSSMRA80A01H501U
|
||||||
@@ -743,7 +746,7 @@ en:
|
|||||||
replay_archive_link: "Past live streams — replay archive"
|
replay_archive_link: "Past live streams — replay archive"
|
||||||
schedule_match_link: "Schedule a match"
|
schedule_match_link: "Schedule a match"
|
||||||
search_placeholder_club: "Search club, team, opponent or venue…"
|
search_placeholder_club: "Search club, team, opponent or venue…"
|
||||||
search_placeholder_default: "E.g. Crazy Volley, Serie D, opponent…"
|
search_placeholder_default: "E.g. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Search team"
|
search_aria_label: "Search team"
|
||||||
search_button: "Search"
|
search_button: "Search"
|
||||||
reset_link: "Reset"
|
reset_link: "Reset"
|
||||||
@@ -777,7 +780,7 @@ en:
|
|||||||
empty_hero_cta_features: "See how it works"
|
empty_hero_cta_features: "See how it works"
|
||||||
demo_aria_label: "Example of an active live stream"
|
demo_aria_label: "Example of an active live stream"
|
||||||
demo_label: "Example — this is how an active stream looks"
|
demo_label: "Example — this is how an active stream looks"
|
||||||
demo_meta: "PalaTigers · Match Live TV"
|
demo_meta: "Pala MLTV · Match Live TV"
|
||||||
demo_sets: "Set 2 · Sets won 1-0"
|
demo_sets: "Set 2 · Sets won 1-0"
|
||||||
show:
|
show:
|
||||||
back_to_all: "← All live streams"
|
back_to_all: "← All live streams"
|
||||||
@@ -810,7 +813,7 @@ en:
|
|||||||
back_to_live: "← Live streams"
|
back_to_live: "← Live streams"
|
||||||
title: "Past live streams"
|
title: "Past live streams"
|
||||||
hint: "Public replays from sports clubs — covers matches already broadcast."
|
hint: "Public replays from sports clubs — covers matches already broadcast."
|
||||||
search_placeholder: "E.g. Tigers Volley, opponent, club, venue…"
|
search_placeholder: "E.g. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Search replays"
|
search_aria_label: "Search replays"
|
||||||
search_button: "Search"
|
search_button: "Search"
|
||||||
reset_link: "Reset"
|
reset_link: "Reset"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
es:
|
es:
|
||||||
|
api:
|
||||||
|
errors:
|
||||||
|
user_concurrent_stream: "Ya tienes un directo en curso con esta cuenta. Ciérralo antes de iniciar otro."
|
||||||
password_policy:
|
password_policy:
|
||||||
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
|
hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos."
|
||||||
activerecord:
|
activerecord:
|
||||||
@@ -77,10 +80,10 @@ es:
|
|||||||
title: "Registrar un club — Match Live TV"
|
title: "Registrar un club — Match Live TV"
|
||||||
meta_description: Crea tu club deportivo y su primer equipo en Match Live TV.
|
meta_description: Crea tu club deportivo y su primer equipo en Match Live TV.
|
||||||
heading: Tu club
|
heading: Tu club
|
||||||
lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, Serie C…)."
|
lead: "Registra el club: podrás añadir más equipos (Sub-13, Sub-15, primer equipo…)."
|
||||||
section_club: Club
|
section_club: Club
|
||||||
name_label: Nombre del club
|
name_label: Nombre del club
|
||||||
name_placeholder: "ej. Crazy Volley Rozzano"
|
name_placeholder: "ej. Team MLTV"
|
||||||
section_first_team: Primer equipo
|
section_first_team: Primer equipo
|
||||||
default_first_team_name: Primer equipo
|
default_first_team_name: Primer equipo
|
||||||
first_team_name_label: Nombre del equipo
|
first_team_name_label: Nombre del equipo
|
||||||
@@ -159,7 +162,7 @@ es:
|
|||||||
heading: Nuevo equipo
|
heading: Nuevo equipo
|
||||||
club_label: "Club:"
|
club_label: "Club:"
|
||||||
name_label: Nombre del equipo
|
name_label: Nombre del equipo
|
||||||
name_placeholder: "ej. Sub-13, Serie C"
|
name_placeholder: "ej. U15 masculino"
|
||||||
branding_legend: Personalización de imagen de marca (opcional)
|
branding_legend: Personalización de imagen de marca (opcional)
|
||||||
submit: Añadir equipo
|
submit: Añadir equipo
|
||||||
invite:
|
invite:
|
||||||
@@ -235,7 +238,7 @@ es:
|
|||||||
matches:
|
matches:
|
||||||
back_to_list: "← Lista de partidos"
|
back_to_list: "← Lista de partidos"
|
||||||
opponent_label: Rival
|
opponent_label: Rival
|
||||||
opponent_placeholder: "ej. Volley Milano"
|
opponent_placeholder: "ej. Team Guest"
|
||||||
location_label: "Lugar (opcional)"
|
location_label: "Lugar (opcional)"
|
||||||
location_placeholder: "Pabellón, ciudad"
|
location_placeholder: "Pabellón, ciudad"
|
||||||
datetime_label: Fecha y hora
|
datetime_label: Fecha y hora
|
||||||
@@ -361,7 +364,7 @@ es:
|
|||||||
legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe."
|
legend_hint: "Todos los campos marcados son obligatorios para suscribirte a un plan premium y para emitir facturas. Los clubes necesitan NIF/CIF; las personas físicas, el código fiscal. Necesitas SDI o correo certificado (PEC). Los pagos siguen gestionados de forma segura por Stripe."
|
||||||
entity_type_label: "Tipo de titular *"
|
entity_type_label: "Tipo de titular *"
|
||||||
legal_name_label: "Razón social / nombre y apellidos *"
|
legal_name_label: "Razón social / nombre y apellidos *"
|
||||||
legal_name_placeholder: "ej. ASD Tigers Volley"
|
legal_name_placeholder: "ej. Team MLTV"
|
||||||
vat_number_label: "NIF/CIF * (club)"
|
vat_number_label: "NIF/CIF * (club)"
|
||||||
fiscal_code_label: "Código fiscal * (persona física)"
|
fiscal_code_label: "Código fiscal * (persona física)"
|
||||||
fiscal_code_placeholder: RSSMRA80A01H501U
|
fiscal_code_placeholder: RSSMRA80A01H501U
|
||||||
@@ -738,7 +741,7 @@ es:
|
|||||||
replay_archive_link: "Directos pasados — archivo de repeticiones"
|
replay_archive_link: "Directos pasados — archivo de repeticiones"
|
||||||
schedule_match_link: "Programar partido"
|
schedule_match_link: "Programar partido"
|
||||||
search_placeholder_club: "Buscar club, equipo, rival o lugar…"
|
search_placeholder_club: "Buscar club, equipo, rival o lugar…"
|
||||||
search_placeholder_default: "Ej. Crazy Volley, Serie D, rival…"
|
search_placeholder_default: "Ej. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Buscar equipo"
|
search_aria_label: "Buscar equipo"
|
||||||
search_button: "Buscar"
|
search_button: "Buscar"
|
||||||
reset_link: "Restablecer"
|
reset_link: "Restablecer"
|
||||||
@@ -772,7 +775,7 @@ es:
|
|||||||
empty_hero_cta_features: "Descubre cómo funciona"
|
empty_hero_cta_features: "Descubre cómo funciona"
|
||||||
demo_aria_label: "Ejemplo de un directo activo"
|
demo_aria_label: "Ejemplo de un directo activo"
|
||||||
demo_label: "Ejemplo — así se ve un directo activo"
|
demo_label: "Ejemplo — así se ve un directo activo"
|
||||||
demo_meta: "PalaTigers · Match Live TV"
|
demo_meta: "Pala MLTV · Match Live TV"
|
||||||
demo_sets: "Set 2 · Sets ganados 1-0"
|
demo_sets: "Set 2 · Sets ganados 1-0"
|
||||||
show:
|
show:
|
||||||
back_to_all: "← Todos los directos"
|
back_to_all: "← Todos los directos"
|
||||||
@@ -805,7 +808,7 @@ es:
|
|||||||
back_to_live: "← Directos en curso"
|
back_to_live: "← Directos en curso"
|
||||||
title: "Directos pasados"
|
title: "Directos pasados"
|
||||||
hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos."
|
hint: "Repeticiones públicas de los clubes deportivos — corresponde a partidos ya transmitidos."
|
||||||
search_placeholder: "Ej. Tigers Volley, rival, club, lugar…"
|
search_placeholder: "Ej. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Buscar repetición"
|
search_aria_label: "Buscar repetición"
|
||||||
search_button: "Buscar"
|
search_button: "Buscar"
|
||||||
reset_link: "Restablecer"
|
reset_link: "Restablecer"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
fr:
|
fr:
|
||||||
|
api:
|
||||||
|
errors:
|
||||||
|
user_concurrent_stream: "Un direct est déjà en cours avec ce compte. Arrêtez-le avant d’en démarrer un autre."
|
||||||
password_policy:
|
password_policy:
|
||||||
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
|
hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles."
|
||||||
activerecord:
|
activerecord:
|
||||||
@@ -77,10 +80,10 @@ fr:
|
|||||||
title: "Inscrire un club — Match Live TV"
|
title: "Inscrire un club — Match Live TV"
|
||||||
meta_description: Crée ton club sportif et sa première équipe sur Match Live TV.
|
meta_description: Crée ton club sportif et sa première équipe sur Match Live TV.
|
||||||
heading: Ton club
|
heading: Ton club
|
||||||
lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, Serie C…)."
|
lead: "Inscris le club : tu pourras ajouter d'autres équipes (Under 13, Under 15, première équipe…)."
|
||||||
section_club: Club
|
section_club: Club
|
||||||
name_label: Nom du club
|
name_label: Nom du club
|
||||||
name_placeholder: "ex. Crazy Volley Rozzano"
|
name_placeholder: "ex. Team MLTV"
|
||||||
section_first_team: Première équipe
|
section_first_team: Première équipe
|
||||||
default_first_team_name: Première équipe
|
default_first_team_name: Première équipe
|
||||||
first_team_name_label: Nom de l'équipe
|
first_team_name_label: Nom de l'équipe
|
||||||
@@ -159,7 +162,7 @@ fr:
|
|||||||
heading: Nouvelle équipe
|
heading: Nouvelle équipe
|
||||||
club_label: "Club :"
|
club_label: "Club :"
|
||||||
name_label: Nom de l'équipe
|
name_label: Nom de l'équipe
|
||||||
name_placeholder: "ex. Under 13, Serie C"
|
name_placeholder: "ex. U15 masculin"
|
||||||
branding_legend: Personnalisation de l'image de marque (facultatif)
|
branding_legend: Personnalisation de l'image de marque (facultatif)
|
||||||
submit: Ajouter l'équipe
|
submit: Ajouter l'équipe
|
||||||
invite:
|
invite:
|
||||||
@@ -235,7 +238,7 @@ fr:
|
|||||||
matches:
|
matches:
|
||||||
back_to_list: "← Liste des matchs"
|
back_to_list: "← Liste des matchs"
|
||||||
opponent_label: Adversaire
|
opponent_label: Adversaire
|
||||||
opponent_placeholder: "ex. Volley Milano"
|
opponent_placeholder: "ex. Team Guest"
|
||||||
location_label: "Lieu (facultatif)"
|
location_label: "Lieu (facultatif)"
|
||||||
location_placeholder: "Gymnase, ville"
|
location_placeholder: "Gymnase, ville"
|
||||||
datetime_label: Date et heure
|
datetime_label: Date et heure
|
||||||
@@ -361,7 +364,7 @@ fr:
|
|||||||
legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe."
|
legend_hint: "Tous les champs marqués sont obligatoires pour souscrire à un forfait premium et pour émettre les factures. Les clubs ont besoin d'un numéro de TVA ; les personnes physiques d'un code fiscal. Il faut le SDI ou le PEC. Les paiements restent gérés en toute sécurité par Stripe."
|
||||||
entity_type_label: "Type de titulaire *"
|
entity_type_label: "Type de titulaire *"
|
||||||
legal_name_label: "Raison sociale / nom et prénom *"
|
legal_name_label: "Raison sociale / nom et prénom *"
|
||||||
legal_name_placeholder: "ex. ASD Tigers Volley"
|
legal_name_placeholder: "ex. Team MLTV"
|
||||||
vat_number_label: "Numéro de TVA * (club)"
|
vat_number_label: "Numéro de TVA * (club)"
|
||||||
fiscal_code_label: "Code fiscal * (personne physique)"
|
fiscal_code_label: "Code fiscal * (personne physique)"
|
||||||
fiscal_code_placeholder: RSSMRA80A01H501U
|
fiscal_code_placeholder: RSSMRA80A01H501U
|
||||||
@@ -738,7 +741,7 @@ fr:
|
|||||||
replay_archive_link: "Directs passés — archive des replays"
|
replay_archive_link: "Directs passés — archive des replays"
|
||||||
schedule_match_link: "Programmer un match"
|
schedule_match_link: "Programmer un match"
|
||||||
search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…"
|
search_placeholder_club: "Rechercher club, équipe, adversaire ou lieu…"
|
||||||
search_placeholder_default: "Ex. Crazy Volley, Série D, adversaire…"
|
search_placeholder_default: "Ex. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Rechercher une équipe"
|
search_aria_label: "Rechercher une équipe"
|
||||||
search_button: "Rechercher"
|
search_button: "Rechercher"
|
||||||
reset_link: "Réinitialiser"
|
reset_link: "Réinitialiser"
|
||||||
@@ -772,7 +775,7 @@ fr:
|
|||||||
empty_hero_cta_features: "Découvrez comment ça marche"
|
empty_hero_cta_features: "Découvrez comment ça marche"
|
||||||
demo_aria_label: "Exemple de direct actif"
|
demo_aria_label: "Exemple de direct actif"
|
||||||
demo_label: "Exemple — voici à quoi ressemble un direct actif"
|
demo_label: "Exemple — voici à quoi ressemble un direct actif"
|
||||||
demo_meta: "PalaTigers · Match Live TV"
|
demo_meta: "Pala MLTV · Match Live TV"
|
||||||
demo_sets: "Set 2 · Sets gagnés 1-0"
|
demo_sets: "Set 2 · Sets gagnés 1-0"
|
||||||
show:
|
show:
|
||||||
back_to_all: "← Tous les directs"
|
back_to_all: "← Tous les directs"
|
||||||
@@ -805,7 +808,7 @@ fr:
|
|||||||
back_to_live: "← Directs en cours"
|
back_to_live: "← Directs en cours"
|
||||||
title: "Directs passés"
|
title: "Directs passés"
|
||||||
hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés."
|
hint: "Replays publics des clubs sportifs — concerne les matchs déjà diffusés."
|
||||||
search_placeholder: "Ex. Tigers Volley, adversaire, club, lieu…"
|
search_placeholder: "Ex. Team MLTV, Team Guest…"
|
||||||
search_aria_label: "Rechercher un replay"
|
search_aria_label: "Rechercher un replay"
|
||||||
search_button: "Rechercher"
|
search_button: "Rechercher"
|
||||||
reset_link: "Réinitialiser"
|
reset_link: "Réinitialiser"
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
it:
|
it:
|
||||||
|
api:
|
||||||
|
errors:
|
||||||
|
user_concurrent_stream: "Hai già una diretta in corso con questo account. Chiudila prima di avviarne un’altra."
|
||||||
password_policy:
|
password_policy:
|
||||||
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
|
hint: "Minimo 8 caratteri, con almeno 3 tra: minuscole, maiuscole, numeri e simboli."
|
||||||
activerecord:
|
activerecord:
|
||||||
@@ -82,10 +85,10 @@ it:
|
|||||||
title: "Registra società — Match Live TV"
|
title: "Registra società — Match Live TV"
|
||||||
meta_description: Crea la società sportiva e la prima squadra su Match Live TV.
|
meta_description: Crea la società sportiva e la prima squadra su Match Live TV.
|
||||||
heading: La tua società
|
heading: La tua società
|
||||||
lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, Serie C…)."
|
lead: "Registra il club: potrai aggiungere più squadre (Under 13, Under 15, prima squadra…)."
|
||||||
section_club: Società
|
section_club: Società
|
||||||
name_label: Nome società / club
|
name_label: Nome società / club
|
||||||
name_placeholder: "es. Crazy Volley Rozzano"
|
name_placeholder: "es. Team MLTV"
|
||||||
section_first_team: Prima squadra
|
section_first_team: Prima squadra
|
||||||
default_first_team_name: Prima squadra
|
default_first_team_name: Prima squadra
|
||||||
first_team_name_label: Nome squadra
|
first_team_name_label: Nome squadra
|
||||||
@@ -164,7 +167,7 @@ it:
|
|||||||
heading: Nuova squadra
|
heading: Nuova squadra
|
||||||
club_label: "Società:"
|
club_label: "Società:"
|
||||||
name_label: Nome squadra
|
name_label: Nome squadra
|
||||||
name_placeholder: "es. Under 13, Serie C"
|
name_placeholder: "es. U15 maschile"
|
||||||
branding_legend: Override branding (opzionale)
|
branding_legend: Override branding (opzionale)
|
||||||
submit: Aggiungi squadra
|
submit: Aggiungi squadra
|
||||||
invite:
|
invite:
|
||||||
@@ -240,7 +243,7 @@ it:
|
|||||||
matches:
|
matches:
|
||||||
back_to_list: "← Elenco partite"
|
back_to_list: "← Elenco partite"
|
||||||
opponent_label: Avversario
|
opponent_label: Avversario
|
||||||
opponent_placeholder: "es. Volley Milano"
|
opponent_placeholder: "es. Squadra ospite"
|
||||||
location_label: "Luogo (opzionale)"
|
location_label: "Luogo (opzionale)"
|
||||||
location_placeholder: "Palestra, città"
|
location_placeholder: "Palestra, città"
|
||||||
datetime_label: Data e ora
|
datetime_label: Data e ora
|
||||||
@@ -367,7 +370,7 @@ it:
|
|||||||
legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe."
|
legend_hint: "Tutti i campi contrassegnati sono obbligatori per abbonarti a un piano premium e per emettere le fatture. Per le società serve la P.IVA; per le persone fisiche il Codice Fiscale. Serve SDI oppure PEC. I pagamenti restano gestiti in modo sicuro da Stripe."
|
||||||
entity_type_label: "Tipo intestatario *"
|
entity_type_label: "Tipo intestatario *"
|
||||||
legal_name_label: "Ragione sociale / nome e cognome *"
|
legal_name_label: "Ragione sociale / nome e cognome *"
|
||||||
legal_name_placeholder: "es. ASD Tigers Volley"
|
legal_name_placeholder: "es. Team MLTV"
|
||||||
vat_number_label: "Partita IVA * (società)"
|
vat_number_label: "Partita IVA * (società)"
|
||||||
fiscal_code_label: "Codice Fiscale * (persona fisica)"
|
fiscal_code_label: "Codice Fiscale * (persona fisica)"
|
||||||
fiscal_code_placeholder: RSSMRA80A01H501U
|
fiscal_code_placeholder: RSSMRA80A01H501U
|
||||||
@@ -771,7 +774,7 @@ it:
|
|||||||
replay_archive_link: "Live passate — archivio replay"
|
replay_archive_link: "Live passate — archivio replay"
|
||||||
schedule_match_link: "Programma partita"
|
schedule_match_link: "Programma partita"
|
||||||
search_placeholder_club: "Cerca società, squadra, avversario o luogo…"
|
search_placeholder_club: "Cerca società, squadra, avversario o luogo…"
|
||||||
search_placeholder_default: "Es. Crazy Volley, Serie D, avversario…"
|
search_placeholder_default: "Es. Team MLTV, Squadra ospite…"
|
||||||
search_aria_label: "Cerca squadra"
|
search_aria_label: "Cerca squadra"
|
||||||
search_button: "Cerca"
|
search_button: "Cerca"
|
||||||
reset_link: "Azzera"
|
reset_link: "Azzera"
|
||||||
@@ -805,7 +808,7 @@ it:
|
|||||||
empty_hero_cta_features: "Scopri come funziona"
|
empty_hero_cta_features: "Scopri come funziona"
|
||||||
demo_aria_label: "Esempio di diretta attiva"
|
demo_aria_label: "Esempio di diretta attiva"
|
||||||
demo_label: "Esempio — così appare una diretta attiva"
|
demo_label: "Esempio — così appare una diretta attiva"
|
||||||
demo_meta: "PalaTigers · Match Live TV"
|
demo_meta: "Pala MLTV · Match Live TV"
|
||||||
demo_sets: "Set 2 · Set vinti 1-0"
|
demo_sets: "Set 2 · Set vinti 1-0"
|
||||||
show:
|
show:
|
||||||
back_to_all: "← Tutte le dirette"
|
back_to_all: "← Tutte le dirette"
|
||||||
@@ -838,7 +841,7 @@ it:
|
|||||||
back_to_live: "← Dirette live"
|
back_to_live: "← Dirette live"
|
||||||
title: "Live passate"
|
title: "Live passate"
|
||||||
hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse."
|
hint: "Replay pubblici delle società sportive — riguarda le partite già trasmesse."
|
||||||
search_placeholder: "Es. Tigers Volley, avversario, società, luogo…"
|
search_placeholder: "Es. Team MLTV, Squadra ospite…"
|
||||||
search_aria_label: "Cerca replay"
|
search_aria_label: "Cerca replay"
|
||||||
search_button: "Cerca"
|
search_button: "Cerca"
|
||||||
reset_link: "Azzera"
|
reset_link: "Azzera"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
it:
|
||||||
|
demo:
|
||||||
|
away_team: Squadra ospite
|
||||||
|
when: "Sabato 12 settembre · 10:30"
|
||||||
|
en:
|
||||||
|
demo:
|
||||||
|
away_team: Team Guest
|
||||||
|
when: "Saturday 12 September · 10:30"
|
||||||
|
de:
|
||||||
|
demo:
|
||||||
|
away_team: Team Guest
|
||||||
|
when: "Samstag, 12. September · 10:30"
|
||||||
|
fr:
|
||||||
|
demo:
|
||||||
|
away_team: Team Guest
|
||||||
|
when: "Samedi 12 septembre · 10:30"
|
||||||
|
es:
|
||||||
|
demo:
|
||||||
|
away_team: Team Guest
|
||||||
|
when: "Sábado 12 de septiembre · 10:30"
|
||||||
@@ -111,6 +111,13 @@ de:
|
|||||||
s3_pricing_link_text: Preise
|
s3_pricing_link_text: Preise
|
||||||
s3_p2_html: "Zahlungen für kostenpflichtige Pakete werden über <strong>Stripe</strong> abgewickelt. Mit Abschluss des Checkouts akzeptieren Sie auch die Zahlungsbedingungen von Stripe. Der Anbieter speichert keine vollständigen Kartendaten."
|
s3_p2_html: "Zahlungen für kostenpflichtige Pakete werden über <strong>Stripe</strong> abgewickelt. Mit Abschluss des Checkouts akzeptieren Sie auch die Zahlungsbedingungen von Stripe. Der Anbieter speichert keine vollständigen Kartendaten."
|
||||||
s3_p3: Verlängerungen, Kündigung und Widerrufsrecht richten sich nach den beim Kauf mitgeteilten Angaben und den geltenden Rechtsvorschriften.
|
s3_p3: Verlängerungen, Kündigung und Widerrufsrecht richten sich nach den beim Kauf mitgeteilten Angaben und den geltenden Rechtsvorschriften.
|
||||||
|
s3b_title: 3-bis. Garantie „Zufrieden oder Geld zurück“
|
||||||
|
s3b_p1: Die Pakete Premium Light und Premium Full sind durch eine Garantie „Zufrieden oder Geld zurück“ von 30 Tagen ab dem Aktivierungsdatum des ersten von dem Verein (Kunden) erworbenen Match-Live-TV-Abos abgedeckt.
|
||||||
|
s3b_p2: Wenn Sie in diesem Zeitraum der Auffassung sind, dass der Dienst den Bedürfnissen des Vereins nicht entspricht, können Sie die Erstattung von 100 % des tatsächlich für dieses erste Abo gezahlten Betrags beantragen. Der erstattete Betrag entspricht der vollständig gezahlten Summe; etwaige Zahlungsgebühren trägt der Anbieter.
|
||||||
|
s3b_p3_html: "Der Antrag ist innerhalb von 30 Tagen nach der Aktivierung zu stellen, per E-Mail an %{email_link} oder über die auf der Website angegebenen Support-Kanäle, unter Angabe des Vereins und der Abo-Daten."
|
||||||
|
s3b_p4: Die Erstattung erfolgt, soweit technisch möglich, auf dasselbe Zahlungsmittel, das beim Kauf verwendet wurde. Die Gutschriftzeiten hängen auch vom Zahlungsnetz ab; der Anbieter leitet die Bearbeitung in der Regel innerhalb von 14 Tagen nach Bestätigung des Antrags ein.
|
||||||
|
s3b_p5: Die Garantie gilt nur einmal, für das erste vom Verein erworbene Match-Live-TV-Abo. Sie verlängert sich nicht automatisch auf spätere Zeiträume und darf nicht wiederholt von demselben Verein durch Anlegen neuer Konten in Anspruch genommen werden.
|
||||||
|
s3b_p6: Die Garantie soll dem Verein ermöglichen, Match Live TV in den ersten 30 Tagen im normalen Gebrauch zu prüfen. Der Anbieter behält sich vor, ihre Anwendung ausschließlich bei Nutzungen auszuschließen, die diesem Zweck eindeutig fremd sind, etwa einer vorübergehenden Aktivierung allein zur intensiven Abdeckung eines einzelnen Events oder Turniers, gefolgt von einem Erstattungsantrag.
|
||||||
s4_title: 4. Livestreams, Inhalte und Schutz Minderjähriger
|
s4_title: 4. Livestreams, Inhalte und Schutz Minderjähriger
|
||||||
s4_lead1: Verantwortung des Sportvereins
|
s4_lead1: Verantwortung des Sportvereins
|
||||||
s4_p1_html: "Der Verein, der einen Livestream startet, ist verantwortlich für die übertragenen Inhalte (Bilder, Audio, Kommentare) sowie für die Einhaltung der Gesetze, des Verbandsreglements und der erforderlichen Einwilligungen, insbesondere wenn <strong>minderjährige Athleten</strong> gefilmt werden."
|
s4_p1_html: "Der Verein, der einen Livestream startet, ist verantwortlich für die übertragenen Inhalte (Bilder, Audio, Kommentare) sowie für die Einhaltung der Gesetze, des Verbandsreglements und der erforderlichen Einwilligungen, insbesondere wenn <strong>minderjährige Athleten</strong> gefilmt werden."
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ en:
|
|||||||
s3_pricing_link_text: Pricing
|
s3_pricing_link_text: Pricing
|
||||||
s3_p2_html: "Payments for paid plans are handled by <strong>Stripe</strong>. By completing checkout, you also accept Stripe's terms for payment. The Provider does not store full card details."
|
s3_p2_html: "Payments for paid plans are handled by <strong>Stripe</strong>. By completing checkout, you also accept Stripe's terms for payment. The Provider does not store full card details."
|
||||||
s3_p3: Renewals, cancellation and the right of withdrawal follow what was communicated at the time of purchase and applicable law.
|
s3_p3: Renewals, cancellation and the right of withdrawal follow what was communicated at the time of purchase and applicable law.
|
||||||
|
s3b_title: 3-bis. Satisfied or refunded guarantee
|
||||||
|
s3b_p1: Premium Light and Premium Full plans are covered by a “Satisfied or refunded” guarantee lasting 30 days from the activation date of the first Match Live TV subscription purchased by the club (customer).
|
||||||
|
s3b_p2: If within that period you consider the service is not suitable for the club’s needs, you may request a refund of 100% of the amount actually paid for that first subscription. The refunded amount equals the full sum paid; any payment processing fees remain borne by the Provider.
|
||||||
|
s3b_p3_html: "The request must be sent within 30 days of activation, by writing to %{email_link} or via the support channels indicated on the site, stating the club and the subscription details."
|
||||||
|
s3b_p4: The refund is made, where technically possible, to the same payment method used for the purchase. Credit times also depend on the payment network; the Provider normally starts processing within 14 days of confirming the request.
|
||||||
|
s3b_p5: The guarantee applies only once, to the first Match Live TV subscription purchased by the club. It does not automatically renew for later periods and cannot be used repeatedly by the same club through the creation of new accounts.
|
||||||
|
s3b_p6: The guarantee is intended to allow the club to evaluate Match Live TV in normal use during the first 30 days. The Provider reserves the right to exclude its application solely in cases of use clearly unrelated to that purpose, such as a temporary activation aimed exclusively at intensive coverage of a single event or tournament, followed by a refund request.
|
||||||
s4_title: 4. Live broadcasts, content and protection of minors
|
s4_title: 4. Live broadcasts, content and protection of minors
|
||||||
s4_lead1: Responsibility of the sports club
|
s4_lead1: Responsibility of the sports club
|
||||||
s4_p1_html: "The club that starts a live broadcast is responsible for the content transmitted (images, audio, comments) and for compliance with the law, federation regulations and the necessary consents, in particular when <strong>minor athletes</strong> are filmed."
|
s4_p1_html: "The club that starts a live broadcast is responsible for the content transmitted (images, audio, comments) and for compliance with the law, federation regulations and the necessary consents, in particular when <strong>minor athletes</strong> are filmed."
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ es:
|
|||||||
s3_pricing_link_text: Precios
|
s3_pricing_link_text: Precios
|
||||||
s3_p2_html: "Los pagos de los planes de pago se gestionan a través de <strong>Stripe</strong>. Al aceptar el checkout, también aceptas los términos de Stripe para el pago. El Proveedor no almacena los datos completos de la tarjeta."
|
s3_p2_html: "Los pagos de los planes de pago se gestionan a través de <strong>Stripe</strong>. Al aceptar el checkout, también aceptas los términos de Stripe para el pago. El Proveedor no almacena los datos completos de la tarjeta."
|
||||||
s3_p3: Las renovaciones, la baja y el derecho de desistimiento siguen lo comunicado en el momento de la compra y la normativa aplicable.
|
s3_p3: Las renovaciones, la baja y el derecho de desistimiento siguen lo comunicado en el momento de la compra y la normativa aplicable.
|
||||||
|
s3b_title: 3-bis. Garantía Satisfechos o reembolsados
|
||||||
|
s3b_p1: Los planes Premium Light y Premium Full están cubiertos por una garantía «Satisfechos o reembolsados» de 30 días desde la fecha de activación de la primera suscripción Match Live TV adquirida por el club (cliente).
|
||||||
|
s3b_p2: Si en ese periodo consideras que el servicio no se adapta a las necesidades del club, puedes solicitar el reembolso del 100% del importe efectivamente pagado por esa primera suscripción. El importe reembolsado equivale a la suma íntegra abonada; las eventuales comisiones de pago corren a cargo del Proveedor.
|
||||||
|
s3b_p3_html: "La solicitud debe enviarse dentro de los 30 días desde la activación, escribiendo a %{email_link} o a través de los canales de soporte indicados en el sitio, indicando el club y los datos de la suscripción."
|
||||||
|
s3b_p4: El reembolso se efectúa, cuando sea técnicamente posible, en el mismo método de pago utilizado en la compra. Los plazos de abono también dependen de la red de pago; el Proveedor suele iniciar el trámite en un plazo de 14 días desde la confirmación de la solicitud.
|
||||||
|
s3b_p5: La garantía se aplica una sola vez, a la primera suscripción Match Live TV adquirida por el club. No se renueva automáticamente en periodos posteriores y no puede utilizarse de forma reiterada por el mismo club mediante la creación de nuevas cuentas.
|
||||||
|
s3b_p6: La garantía está pensada para que el club evalúe Match Live TV en un uso normal durante los primeros 30 días. El Proveedor se reserva el derecho de excluir su aplicación únicamente en caso de usos manifiestamente ajenos a esa finalidad, como una activación temporal destinada exclusivamente a la cobertura intensiva de un evento o torneo aislado, seguida de una solicitud de reembolso.
|
||||||
s4_title: 4. Directos, contenidos y protección de menores
|
s4_title: 4. Directos, contenidos y protección de menores
|
||||||
s4_lead1: Responsabilidad del club deportivo
|
s4_lead1: Responsabilidad del club deportivo
|
||||||
s4_p1_html: "El club que inicia un directo es responsable de los contenidos transmitidos (imágenes, audio, comentarios) y del cumplimiento de las leyes, el reglamento federativo y los consentimientos necesarios, en particular cuando se filma a <strong>deportistas menores de edad</strong>."
|
s4_p1_html: "El club que inicia un directo es responsable de los contenidos transmitidos (imágenes, audio, comentarios) y del cumplimiento de las leyes, el reglamento federativo y los consentimientos necesarios, en particular cuando se filma a <strong>deportistas menores de edad</strong>."
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ fr:
|
|||||||
s3_pricing_link_text: Tarifs
|
s3_pricing_link_text: Tarifs
|
||||||
s3_p2_html: "Les paiements des forfaits payants sont gérés par <strong>Stripe</strong>. En validant le paiement, vous acceptez également les conditions de Stripe pour le paiement. Le Fournisseur ne conserve pas les données complètes de la carte."
|
s3_p2_html: "Les paiements des forfaits payants sont gérés par <strong>Stripe</strong>. En validant le paiement, vous acceptez également les conditions de Stripe pour le paiement. Le Fournisseur ne conserve pas les données complètes de la carte."
|
||||||
s3_p3: Les renouvellements, la résiliation et le droit de rétractation suivent ce qui a été communiqué au moment de l'achat et la réglementation applicable.
|
s3_p3: Les renouvellements, la résiliation et le droit de rétractation suivent ce qui a été communiqué au moment de l'achat et la réglementation applicable.
|
||||||
|
s3b_title: 3-bis. Garantie Satisfait ou remboursé
|
||||||
|
s3b_p1: Les forfaits Premium Light et Premium Full sont couverts par une garantie « Satisfait ou remboursé » d'une durée de 30 jours à compter de la date d'activation du premier abonnement Match Live TV acheté par le club (client).
|
||||||
|
s3b_p2: Si dans ce délai vous estimez que le service ne convient pas aux besoins du club, vous pouvez demander le remboursement de 100 % du montant effectivement payé pour ce premier abonnement. Le montant remboursé correspond à la somme intégralement versée ; d'éventuelles commissions de paiement restent à la charge du Fournisseur.
|
||||||
|
s3b_p3_html: "La demande doit être envoyée dans les 30 jours suivant l'activation, en écrivant à %{email_link} ou via les canaux d'assistance indiqués sur le site, en précisant le club et les références de l'abonnement."
|
||||||
|
s3b_p4: Le remboursement est effectué, lorsque c'est techniquement possible, sur le même moyen de paiement que celui utilisé pour l'achat. Les délais de crédit dépendent aussi du réseau de paiement ; le Fournisseur engage en principe le traitement dans les 14 jours suivant la confirmation de la demande.
|
||||||
|
s3b_p5: La garantie s'applique une seule fois, au premier abonnement Match Live TV acheté par le club. Elle ne se renouvelle pas automatiquement sur les périodes suivantes et ne peut pas être utilisée de manière répétée par le même club via la création de nouveaux comptes.
|
||||||
|
s3b_p6: La garantie vise à permettre au club d'évaluer Match Live TV dans un usage normal pendant les 30 premiers jours. Le Fournisseur se réserve le droit d'en exclure l'application uniquement en cas d'usages manifestement étrangers à cette finalité, tels qu'une activation temporaire destinée exclusivement à la couverture intensive d'un événement ou d'un tournoi isolé, suivie d'une demande de remboursement.
|
||||||
s4_title: 4. Directs, contenus et protection des mineurs
|
s4_title: 4. Directs, contenus et protection des mineurs
|
||||||
s4_lead1: Responsabilité du club sportif
|
s4_lead1: Responsabilité du club sportif
|
||||||
s4_p1_html: "Le club qui lance un direct est responsable des contenus diffusés (images, audio, commentaires) et de la conformité aux lois, au règlement fédéral et aux consentements nécessaires, en particulier lorsque des <strong>athlètes mineurs</strong> sont filmés."
|
s4_p1_html: "Le club qui lance un direct est responsable des contenus diffusés (images, audio, commentaires) et de la conformité aux lois, au règlement fédéral et aux consentements nécessaires, en particulier lorsque des <strong>athlètes mineurs</strong> sont filmés."
|
||||||
|
|||||||
@@ -111,6 +111,13 @@ it:
|
|||||||
s3_pricing_link_text: Prezzi
|
s3_pricing_link_text: Prezzi
|
||||||
s3_p2_html: "I pagamenti dei piani a pagamento sono gestiti da <strong>Stripe</strong>. Accettando il checkout, accetti anche i termini di Stripe per il pagamento. Il Fornitore non memorizza i dati completi della carta."
|
s3_p2_html: "I pagamenti dei piani a pagamento sono gestiti da <strong>Stripe</strong>. Accettando il checkout, accetti anche i termini di Stripe per il pagamento. Il Fornitore non memorizza i dati completi della carta."
|
||||||
s3_p3: Rinnovi, disdetta e diritto di recesso seguono quanto comunicato al momento dell’acquisto e la normativa applicabile.
|
s3_p3: Rinnovi, disdetta e diritto di recesso seguono quanto comunicato al momento dell’acquisto e la normativa applicabile.
|
||||||
|
s3b_title: 3-bis. Garanzia Soddisfatti o rimborsati
|
||||||
|
s3b_p1: I piani Premium Light e Premium Full sono coperti da una garanzia «Soddisfatti o rimborsati» della durata di 30 giorni dalla data di attivazione del primo abbonamento Match Live TV acquistato dalla società (cliente).
|
||||||
|
s3b_p2: Se entro tale periodo ritieni che il servizio non sia adatto alle esigenze della società, puoi richiedere il rimborso del 100% dell’importo effettivamente pagato per quel primo abbonamento. L’importo rimborsato è pari all’intera somma versata; eventuali commissioni di pagamento restano a carico del Fornitore.
|
||||||
|
s3b_p3_html: "La richiesta va inviata entro i 30 giorni dall’attivazione, scrivendo a %{email_link} oppure tramite i canali di supporto indicati sul sito, indicando la società e i riferimenti dell’abbonamento."
|
||||||
|
s3b_p4: Il rimborso è disposto, ove tecnicamente possibile, sullo stesso metodo di pagamento utilizzato per l’acquisto. I tempi di accredito dipendono anche dal circuito di pagamento; di norma il Fornitore avvia l’elaborazione entro 14 giorni dalla conferma della richiesta.
|
||||||
|
s3b_p5: La garanzia si applica una sola volta, al primo abbonamento Match Live TV acquistato dalla società. Non si rinnova automaticamente su periodi successivi e non può essere utilizzata ripetutamente dalla stessa società tramite la creazione di nuovi account.
|
||||||
|
s3b_p6: La garanzia è destinata a consentire alla società di valutare Match Live TV nel normale utilizzo durante i primi 30 giorni. Il Fornitore si riserva di escluderne l’applicazione esclusivamente in caso di utilizzi manifestamente estranei a tale finalità, quali l’attivazione temporanea finalizzata esclusivamente alla copertura intensiva di un singolo evento o torneo, seguita dalla richiesta di rimborso.
|
||||||
s4_title: 4. Dirette, contenuti e protezione dei minori
|
s4_title: 4. Dirette, contenuti e protezione dei minori
|
||||||
s4_lead1: Responsabilità della società sportiva
|
s4_lead1: Responsabilità della società sportiva
|
||||||
s4_p1_html: "La società che avvia una diretta è responsabile dei contenuti trasmessi (immagini, audio, commenti) e della conformità alle leggi, al regolamento federale e ai consensi necessari, in particolare quando sono ripresi <strong>atleti minorenni</strong>."
|
s4_p1_html: "La società che avvia una diretta è responsabile dei contenuti trasmessi (immagini, audio, commenti) e della conformità alle leggi, al regolamento federale e ai consensi necessari, in particolare quando sono ripresi <strong>atleti minorenni</strong>."
|
||||||
|
|||||||
@@ -59,11 +59,8 @@ de:
|
|||||||
replay_item_download: Video bei Bedarf aufs Handy herunterladen
|
replay_item_download: Video bei Bedarf aufs Handy herunterladen
|
||||||
replay_card_title: Spielarchiv
|
replay_card_title: Spielarchiv
|
||||||
replay_card_body: Spiele nach dem Schlusspfiff noch einmal ansehen und bei Bedarf herunterladen.
|
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_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_2_meta: "05/05 · 01:18:22"
|
||||||
replay_mock_3_title: Rossi vs Bianchi
|
|
||||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
more_title: Auch das gehört zum Produkt
|
more_title: Auch das gehört zum Produkt
|
||||||
more_stable_title: Livestream vom Smartphone
|
more_stable_title: Livestream vom Smartphone
|
||||||
@@ -76,6 +73,19 @@ de:
|
|||||||
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
|
cta_body: Probieren Sie Match Live TV kostenlos. Keine spezielle Ausrüstung nötig.
|
||||||
cta_primary: Kostenlos starten
|
cta_primary: Kostenlos starten
|
||||||
cta_secondary: Pläne vergleichen
|
cta_secondary: Pläne vergleichen
|
||||||
|
sponsor_cover:
|
||||||
|
eyebrow: Premium Full
|
||||||
|
title: Gib deinen Sponsoren Sichtbarkeit
|
||||||
|
body: Mit Premium Full kannst du das Titelbild jedes Spiels anpassen, indem du eine Grafik eures Vereins hochlädst. Darin könnt ihr Sponsoren, Partner, Logos und Botschaften unterbringen und ihnen schon vor dem Livestream Sichtbarkeit geben.
|
||||||
|
item_cover: Titelbild für jedes Spiel hochladen
|
||||||
|
item_before: Grafik sichtbar im Pre-Live
|
||||||
|
item_club: Die Identität eures Vereins, kein generisches Template
|
||||||
|
claim: Euer Verein. Eure Sponsoren. Euer Livestream.
|
||||||
|
cta: Premium Full entdecken
|
||||||
|
mock:
|
||||||
|
state: PRE-LIVE
|
||||||
|
art_label: Individuelles Titelbild
|
||||||
|
soon: Der Livestream beginnt in Kürze
|
||||||
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."
|
||||||
@@ -88,8 +98,9 @@ de:
|
|||||||
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
|
||||||
table_replay: Server-Replay
|
table_replay: Replay-Archiv
|
||||||
table_download: Handy-Download
|
table_download: Handy-Download
|
||||||
|
table_cover_sponsor: Individuelles Titelbild
|
||||||
table_price: Preis
|
table_price: Preis
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_note: "Listenpreis %{list} — %{monthly}"
|
table_price_note: "Listenpreis %{list} — %{monthly}"
|
||||||
@@ -121,6 +132,7 @@ de:
|
|||||||
youtube_mltv: Match Live TV
|
youtube_mltv: Match Live TV
|
||||||
youtube_club: Vereinskanal
|
youtube_club: Vereinskanal
|
||||||
youtube_none: nein
|
youtube_none: nein
|
||||||
|
cover_sponsor_html: "Anpassbares Titelbild <strong>mit Sponsoren</strong>"
|
||||||
complete_billing: Rechnungsdaten vervollständigen
|
complete_billing: Rechnungsdaten vervollständigen
|
||||||
start_free: Kostenlos starten
|
start_free: Kostenlos starten
|
||||||
register_with_price: "Registrieren — %{price}"
|
register_with_price: "Registrieren — %{price}"
|
||||||
@@ -137,7 +149,7 @@ de:
|
|||||||
q3_answer_html: "Ja. Die Satz-Zählweise ist für Volleyball gemacht; Sie können die Regeln für besondere Turniere anpassen. Entdecken Sie die eigene Seite: %{volleyball_link}."
|
q3_answer_html: "Ja. Die Satz-Zählweise ist für Volleyball gemacht; Sie können die Regeln für besondere Turniere anpassen. Entdecken Sie die eigene Seite: %{volleyball_link}."
|
||||||
q3_volleyball_link: Match Live TV für Jugendvolleyball
|
q3_volleyball_link: Match Live TV für Jugendvolleyball
|
||||||
q4_question: Was passiert, wenn ich den Livestream verpasse?
|
q4_question: Was passiert, wenn ich den Livestream verpasse?
|
||||||
q4_answer: "Mit den Plänen Premium Light oder Full wird das Spiel im Archiv gespeichert (30 oder 90 Tage) und Sie können es auf der Website erneut ansehen. Der Free-Plan enthält kein Server-Replay, aber der Livestream bleibt für Streamer und Zuschauer kostenlos."
|
q4_answer: "Mit den Plänen Premium Light oder Full wird das Spiel im Archiv gespeichert (30 oder 90 Tage) und Sie können es auf der Website erneut ansehen. Der Free-Plan enthält kein Replay-Archiv, aber der Livestream bleibt für Streamer und Zuschauer kostenlos."
|
||||||
q5_question: Was kostet es für den Verein?
|
q5_question: Was kostet es für den Verein?
|
||||||
q5_answer_html: "Sie können mit dem <strong>Free</strong>-Plan starten (begrenztes Team und ein Livestream gleichzeitig). Premium Light und Full bieten mehr parallele Spiele, Archiv und YouTube. %{pricing_link}."
|
q5_answer_html: "Sie können mit dem <strong>Free</strong>-Plan starten (begrenztes Team und ein Livestream gleichzeitig). Premium Light und Full bieten mehr parallele Spiele, Archiv und YouTube. %{pricing_link}."
|
||||||
q5_pricing_link: Preise vergleichen
|
q5_pricing_link: Preise vergleichen
|
||||||
@@ -147,6 +159,11 @@ de:
|
|||||||
q7_answer: "Mit Premium Light können Sie ihn auf den Match-Live-TV-Kanal senden; mit Premium Full auch auf den YouTube-Kanal des Vereins. Wer möchte, bleibt beim Match-Live-TV-Link, praktisch für Familien."
|
q7_answer: "Mit Premium Light können Sie ihn auf den Match-Live-TV-Kanal senden; mit Premium Full auch auf den YouTube-Kanal des Vereins. Wer möchte, bleibt beim Match-Live-TV-Link, praktisch für Familien."
|
||||||
q8_question: Muss ich Router-Ports öffnen oder TV-Ausrüstung haben?
|
q8_question: Muss ich Router-Ports öffnen oder TV-Ausrüstung haben?
|
||||||
q8_answer: "Nein, nicht für Zuschauer. Für den streamenden Verein reichen ein Smartphone und eine gute Verbindung in der Halle; das technische Team der Plattform kümmert sich um die Infrastruktur. Keine Broadcast-Kameras oder Mischpulte nötig."
|
q8_answer: "Nein, nicht für Zuschauer. Für den streamenden Verein reichen ein Smartphone und eine gute Verbindung in der Halle; das technische Team der Plattform kümmert sich um die Infrastruktur. Keine Broadcast-Kameras oder Mischpulte nötig."
|
||||||
|
q9_question: Wie funktioniert die Garantie „Zufrieden oder Geld zurück“?
|
||||||
|
q9_answer_html: "Sie können Match Live TV 30 Tage ab Aktivierung des ersten Abos mit Ihrem Verein testen. Wenn die Plattform in diesem Zeitraum nicht zu Ihren Anforderungen passt, können Sie die Erstattung des vollen gezahlten Betrags beantragen. Weitere Details finden Sie in den %{terms_link}."
|
||||||
|
q9_terms_link: Nutzungsbedingungen
|
||||||
|
q10_question: Kann ich die Sponsoren des Vereins in den Übertragungen zeigen?
|
||||||
|
q10_answer: Mit Premium Full kannst du ein individuelles Titelbild für das Spiel hochladen. Der Verein bereitet die eigene Grafik vor und kann darin Sponsoren, Partner, Logos und Botschaften unterbringen. Das Titelbild erscheint vor Beginn des Livestreams und gibt den Unterstützern des Clubs Sichtbarkeit.
|
||||||
cta_signup: Team registrieren
|
cta_signup: Team registrieren
|
||||||
cta_live: Live-Spiele ansehen
|
cta_live: Live-Spiele ansehen
|
||||||
volleyball:
|
volleyball:
|
||||||
|
|||||||
@@ -59,11 +59,8 @@ en:
|
|||||||
replay_item_download: Download the video to your phone when you need it
|
replay_item_download: Download the video to your phone when you need it
|
||||||
replay_card_title: Match archive
|
replay_card_title: Match archive
|
||||||
replay_card_body: Rewatch matches after the final whistle and download them when you need them.
|
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_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_2_meta: "05/05 · 01:18:22"
|
||||||
replay_mock_3_title: Rossi vs Bianchi
|
|
||||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
more_title: Also part of the product
|
more_title: Also part of the product
|
||||||
more_stable_title: Stream from your smartphone
|
more_stable_title: Stream from your smartphone
|
||||||
@@ -76,6 +73,19 @@ en:
|
|||||||
cta_body: Try Match Live TV for free. No dedicated equipment needed.
|
cta_body: Try Match Live TV for free. No dedicated equipment needed.
|
||||||
cta_primary: Start for free
|
cta_primary: Start for free
|
||||||
cta_secondary: Compare plans
|
cta_secondary: Compare plans
|
||||||
|
sponsor_cover:
|
||||||
|
eyebrow: Premium Full
|
||||||
|
title: Give your sponsors visibility
|
||||||
|
body: With Premium Full you can customise each match cover by uploading artwork from your club. You can include sponsors, partners, logos and messages in that graphic, giving them visibility before the live stream starts.
|
||||||
|
item_cover: A cover uploaded for every match
|
||||||
|
item_before: Artwork shown in the pre-live screen
|
||||||
|
item_club: Your club’s identity, not a generic template
|
||||||
|
claim: Your club. Your sponsors. Your stream.
|
||||||
|
cta: Discover Premium Full
|
||||||
|
mock:
|
||||||
|
state: PRE-LIVE
|
||||||
|
art_label: Custom cover
|
||||||
|
soon: The live stream will start shortly
|
||||||
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."
|
||||||
@@ -88,8 +98,9 @@ en:
|
|||||||
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
|
||||||
table_replay: Server replay
|
table_replay: Replay archive
|
||||||
table_download: Phone download
|
table_download: Phone download
|
||||||
|
table_cover_sponsor: Custom cover
|
||||||
table_price: Price
|
table_price: Price
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_note: "list %{list} — %{monthly}"
|
table_price_note: "list %{list} — %{monthly}"
|
||||||
@@ -121,6 +132,7 @@ en:
|
|||||||
youtube_mltv: Match Live TV
|
youtube_mltv: Match Live TV
|
||||||
youtube_club: club channel
|
youtube_club: club channel
|
||||||
youtube_none: "no"
|
youtube_none: "no"
|
||||||
|
cover_sponsor_html: "Customisable cover <strong>with sponsors</strong>"
|
||||||
complete_billing: Complete billing details
|
complete_billing: Complete billing details
|
||||||
start_free: Start for free
|
start_free: Start for free
|
||||||
register_with_price: "Register — %{price}"
|
register_with_price: "Register — %{price}"
|
||||||
@@ -137,7 +149,7 @@ en:
|
|||||||
q3_answer_html: "Yes. The set-based scoring is built for volleyball; you can customize the rules for special tournaments. Check out the dedicated page: %{volleyball_link}."
|
q3_answer_html: "Yes. The set-based scoring is built for volleyball; you can customize the rules for special tournaments. Check out the dedicated page: %{volleyball_link}."
|
||||||
q3_volleyball_link: Match Live TV for youth volleyball
|
q3_volleyball_link: Match Live TV for youth volleyball
|
||||||
q4_question: What happens if I miss the stream?
|
q4_question: What happens if I miss the stream?
|
||||||
q4_answer: "With Premium Light or Full plans the match is saved to the archive (30 or 90 days) and you can watch it again from the site. The Free plan doesn't include server replay, but the live stream stays free for both streamers and viewers."
|
q4_answer: "With Premium Light or Full plans the match is saved to the archive (30 or 90 days) and you can watch it again from the site. The Free plan doesn't include the replay archive, but the live stream stays free for both streamers and viewers."
|
||||||
q5_question: How much does it cost for the club?
|
q5_question: How much does it cost for the club?
|
||||||
q5_answer_html: "You can start with the <strong>Free</strong> plan (limited staff and one stream at a time). Premium Light and Full add more concurrent matches, archive and YouTube. %{pricing_link}."
|
q5_answer_html: "You can start with the <strong>Free</strong> plan (limited staff and one stream at a time). Premium Light and Full add more concurrent matches, archive and YouTube. %{pricing_link}."
|
||||||
q5_pricing_link: Compare pricing
|
q5_pricing_link: Compare pricing
|
||||||
@@ -147,6 +159,11 @@ en:
|
|||||||
q7_answer: "With Premium Light you can send it to the Match Live TV channel; with Premium Full also to the club's YouTube channel. Those who prefer can stick to the Match Live TV link, convenient for families."
|
q7_answer: "With Premium Light you can send it to the Match Live TV channel; with Premium Full also to the club's YouTube channel. Those who prefer can stick to the Match Live TV link, convenient for families."
|
||||||
q8_question: Do I need to open router ports or have TV equipment?
|
q8_question: Do I need to open router ports or have TV equipment?
|
||||||
q8_answer: "No, not for viewers. For the club that streams, a smartphone and a good gym connection are enough; the platform's technical staff manages the infrastructure. No broadcast cameras or mixers needed."
|
q8_answer: "No, not for viewers. For the club that streams, a smartphone and a good gym connection are enough; the platform's technical staff manages the infrastructure. No broadcast cameras or mixers needed."
|
||||||
|
q9_question: How does the Satisfied or refunded guarantee work?
|
||||||
|
q9_answer_html: "You can try Match Live TV with your club for 30 days from activation of the first subscription. If within that period you feel the platform is not a good fit, you can request a refund of the full amount paid. For more details see the %{terms_link}."
|
||||||
|
q9_terms_link: Terms and Conditions
|
||||||
|
q10_question: Can I include the club’s sponsors in the broadcasts?
|
||||||
|
q10_answer: With Premium Full you can upload a custom cover for the match. The club prepares its own artwork and can include sponsors, partners, logos and messages in that graphic. The cover is shown before the live stream starts, giving visibility to the organisations that support the club.
|
||||||
cta_signup: Register your team
|
cta_signup: Register your team
|
||||||
cta_live: Watch live matches
|
cta_live: Watch live matches
|
||||||
volleyball:
|
volleyball:
|
||||||
|
|||||||
@@ -59,11 +59,8 @@ es:
|
|||||||
replay_item_download: Descarga del vídeo al móvil cuando haga falta
|
replay_item_download: Descarga del vídeo al móvil cuando haga falta
|
||||||
replay_card_title: Archivo de partidos
|
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_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_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_2_meta: "05/05 · 01:18:22"
|
||||||
replay_mock_3_title: Rossi vs Bianchi
|
|
||||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
more_title: También forma parte del producto
|
more_title: También forma parte del producto
|
||||||
more_stable_title: Directo desde el smartphone
|
more_stable_title: Directo desde el smartphone
|
||||||
@@ -76,6 +73,19 @@ es:
|
|||||||
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
|
cta_body: Prueba Match Live TV gratis. No hace falta equipo dedicado.
|
||||||
cta_primary: Empieza gratis
|
cta_primary: Empieza gratis
|
||||||
cta_secondary: Compara los planes
|
cta_secondary: Compara los planes
|
||||||
|
sponsor_cover:
|
||||||
|
eyebrow: Premium Full
|
||||||
|
title: Da visibilidad a tus patrocinadores
|
||||||
|
body: Con Premium Full puedes personalizar la portada de cada partido subiendo una gráfica de tu club. Así puedes incluir patrocinadores, partners, logotipos y mensajes, y darles visibilidad ya antes de que empiece el directo.
|
||||||
|
item_cover: Portada cargada para cada partido
|
||||||
|
item_before: Gráfica visible en el pre-directo
|
||||||
|
item_club: La identidad de tu club, no una plantilla genérica
|
||||||
|
claim: Tu club. Tus patrocinadores. Tu directo.
|
||||||
|
cta: Descubre Premium Full
|
||||||
|
mock:
|
||||||
|
state: PRE-LIVE
|
||||||
|
art_label: Portada personalizada
|
||||||
|
soon: El directo comenzará en breve
|
||||||
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."
|
||||||
@@ -88,8 +98,9 @@ es:
|
|||||||
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
|
||||||
table_replay: Repetición en servidor
|
table_replay: Archivo de replay
|
||||||
table_download: Descarga al móvil
|
table_download: Descarga al móvil
|
||||||
|
table_cover_sponsor: Portada personalizada
|
||||||
table_price: Precio
|
table_price: Precio
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_note: "tarifa %{list} — %{monthly}"
|
table_price_note: "tarifa %{list} — %{monthly}"
|
||||||
@@ -121,6 +132,7 @@ es:
|
|||||||
youtube_mltv: Match Live TV
|
youtube_mltv: Match Live TV
|
||||||
youtube_club: canal del club
|
youtube_club: canal del club
|
||||||
youtube_none: "no"
|
youtube_none: "no"
|
||||||
|
cover_sponsor_html: "Portada personalizable <strong>con patrocinadores</strong>"
|
||||||
complete_billing: Completar datos de facturación
|
complete_billing: Completar datos de facturación
|
||||||
start_free: Empieza gratis
|
start_free: Empieza gratis
|
||||||
register_with_price: "Regístrate — %{price}"
|
register_with_price: "Regístrate — %{price}"
|
||||||
@@ -137,7 +149,7 @@ es:
|
|||||||
q3_answer_html: "Sí. El marcador por sets está pensado para el voleibol; puedes personalizar reglas para torneos especiales. Descubre la página dedicada: %{volleyball_link}."
|
q3_answer_html: "Sí. El marcador por sets está pensado para el voleibol; puedes personalizar reglas para torneos especiales. Descubre la página dedicada: %{volleyball_link}."
|
||||||
q3_volleyball_link: Match Live TV para voleibol juvenil
|
q3_volleyball_link: Match Live TV para voleibol juvenil
|
||||||
q4_question: "¿Qué pasa si me pierdo el directo?"
|
q4_question: "¿Qué pasa si me pierdo el directo?"
|
||||||
q4_answer: "Con los planes Premium Light o Full el partido se guarda en el archivo (30 o 90 días) y puedes volver a verlo desde el sitio. El plan Free no incluye repetición en servidor, pero el directo sigue siendo gratis para quien emite y para quien mira."
|
q4_answer: "Con los planes Premium Light o Full el partido se guarda en el archivo (30 o 90 días) y puedes volver a verlo desde el sitio. El plan Free no incluye archivo de replay, pero el directo sigue siendo gratis para quien emite y para quien mira."
|
||||||
q5_question: "¿Cuánto cuesta para el club?"
|
q5_question: "¿Cuánto cuesta para el club?"
|
||||||
q5_answer_html: "Puedes empezar con el plan <strong>Free</strong> (límites de staff y un directo a la vez). Premium Light y Full añaden más partidos en paralelo, archivo y YouTube. %{pricing_link}."
|
q5_answer_html: "Puedes empezar con el plan <strong>Free</strong> (límites de staff y un directo a la vez). Premium Light y Full añaden más partidos en paralelo, archivo y YouTube. %{pricing_link}."
|
||||||
q5_pricing_link: Compara precios
|
q5_pricing_link: Compara precios
|
||||||
@@ -147,6 +159,11 @@ es:
|
|||||||
q7_answer: "Con Premium Light puedes enviarlo al canal de Match Live TV; con Premium Full también al canal de YouTube del club. Quien prefiera puede quedarse con el enlace de Match Live TV, cómodo para las familias."
|
q7_answer: "Con Premium Light puedes enviarlo al canal de Match Live TV; con Premium Full también al canal de YouTube del club. Quien prefiera puede quedarse con el enlace de Match Live TV, cómodo para las familias."
|
||||||
q8_question: "¿Tengo que abrir puertos en el router o tener equipo de TV?"
|
q8_question: "¿Tengo que abrir puertos en el router o tener equipo de TV?"
|
||||||
q8_answer: "No, para quien mira no. Para el club que emite basta con el smartphone y una buena conexión en el pabellón; el staff técnico de la plataforma gestiona la infraestructura. Nada de cámaras de retransmisión ni mezcladores."
|
q8_answer: "No, para quien mira no. Para el club que emite basta con el smartphone y una buena conexión en el pabellón; el staff técnico de la plataforma gestiona la infraestructura. Nada de cámaras de retransmisión ni mezcladores."
|
||||||
|
q9_question: "¿Cómo funciona la garantía Satisfechos o reembolsados?"
|
||||||
|
q9_answer_html: "Puedes probar Match Live TV con tu club durante 30 días desde la activación de la primera suscripción. Si en ese periodo consideras que la plataforma no se adapta a tus necesidades, puedes solicitar el reembolso del importe íntegro pagado. Para más detalles consulta los %{terms_link}."
|
||||||
|
q9_terms_link: Términos y Condiciones
|
||||||
|
q10_question: ¿Puedo incluir a los patrocinadores del club en los directos?
|
||||||
|
q10_answer: Con Premium Full puedes subir una portada personalizada para el partido. El club prepara su propia gráfica e incluye patrocinadores, partners, logotipos y mensajes. La portada se muestra antes del inicio del directo, dando visibilidad a quienes apoyan al club.
|
||||||
cta_signup: Registra tu equipo
|
cta_signup: Registra tu equipo
|
||||||
cta_live: Ver directos
|
cta_live: Ver directos
|
||||||
volleyball:
|
volleyball:
|
||||||
|
|||||||
@@ -59,11 +59,8 @@ fr:
|
|||||||
replay_item_download: Téléchargement de la vidéo sur le téléphone si besoin
|
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_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_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_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_2_meta: "05/05 · 01:18:22"
|
||||||
replay_mock_3_title: Rossi vs Bianchi
|
|
||||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
more_title: Cela fait aussi partie du produit
|
more_title: Cela fait aussi partie du produit
|
||||||
more_stable_title: Direct depuis le smartphone
|
more_stable_title: Direct depuis le smartphone
|
||||||
@@ -76,6 +73,19 @@ fr:
|
|||||||
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
|
cta_body: Essayez Match Live TV gratuitement. Aucun matériel dédié nécessaire.
|
||||||
cta_primary: Commencer gratuitement
|
cta_primary: Commencer gratuitement
|
||||||
cta_secondary: Comparer les offres
|
cta_secondary: Comparer les offres
|
||||||
|
sponsor_cover:
|
||||||
|
eyebrow: Premium Full
|
||||||
|
title: Donnez de la visibilité à vos sponsors
|
||||||
|
body: Avec Premium Full, vous pouvez personnaliser la jaquette de chaque match en chargeant un graphisme de votre club. Vous pouvez y intégrer sponsors, partenaires, logos et messages, et leur donner de la visibilité avant le début du direct.
|
||||||
|
item_cover: Jaquette chargée pour chaque match
|
||||||
|
item_before: Graphisme visible en pré-direct
|
||||||
|
item_club: L’identité de votre club, pas un modèle générique
|
||||||
|
claim: Votre club. Vos sponsors. Votre direct.
|
||||||
|
cta: Découvrir Premium Full
|
||||||
|
mock:
|
||||||
|
state: PRE-LIVE
|
||||||
|
art_label: Jaquette personnalisée
|
||||||
|
soon: Le direct va bientôt commencer
|
||||||
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."
|
||||||
@@ -88,8 +98,9 @@ fr:
|
|||||||
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
|
||||||
table_replay: Replay serveur
|
table_replay: Archive replay
|
||||||
table_download: Téléchargement téléphone
|
table_download: Téléchargement téléphone
|
||||||
|
table_cover_sponsor: Jaquette personnalisée
|
||||||
table_price: Prix
|
table_price: Prix
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_note: "tarif %{list} — %{monthly}"
|
table_price_note: "tarif %{list} — %{monthly}"
|
||||||
@@ -121,6 +132,7 @@ fr:
|
|||||||
youtube_mltv: Match Live TV
|
youtube_mltv: Match Live TV
|
||||||
youtube_club: chaîne du club
|
youtube_club: chaîne du club
|
||||||
youtube_none: non
|
youtube_none: non
|
||||||
|
cover_sponsor_html: "Jaquette personnalisable <strong>avec sponsors</strong>"
|
||||||
complete_billing: Compléter les données de facturation
|
complete_billing: Compléter les données de facturation
|
||||||
start_free: Commencer gratuitement
|
start_free: Commencer gratuitement
|
||||||
register_with_price: "S'inscrire — %{price}"
|
register_with_price: "S'inscrire — %{price}"
|
||||||
@@ -137,7 +149,7 @@ fr:
|
|||||||
q3_answer_html: "Oui. Le score par set est pensé pour le volley ; vous pouvez personnaliser les règles pour des tournois particuliers. Découvrez la page dédiée : %{volleyball_link}."
|
q3_answer_html: "Oui. Le score par set est pensé pour le volley ; vous pouvez personnaliser les règles pour des tournois particuliers. Découvrez la page dédiée : %{volleyball_link}."
|
||||||
q3_volleyball_link: Match Live TV pour le volley jeunes
|
q3_volleyball_link: Match Live TV pour le volley jeunes
|
||||||
q4_question: Que se passe-t-il si je rate le direct ?
|
q4_question: Que se passe-t-il si je rate le direct ?
|
||||||
q4_answer: "Avec les offres Premium Light ou Full, le match est enregistré dans l'archive (30 ou 90 jours) et vous pouvez le revoir depuis le site. L'offre Free n'inclut pas le replay serveur, mais le direct reste gratuit pour ceux qui diffusent et pour ceux qui regardent."
|
q4_answer: "Avec les offres Premium Light ou Full, le match est enregistré dans l'archive (30 ou 90 jours) et vous pouvez le revoir depuis le site. L'offre Free n'inclut pas l'archive replay, mais le direct reste gratuit pour ceux qui diffusent et pour ceux qui regardent."
|
||||||
q5_question: Combien ça coûte pour le club ?
|
q5_question: Combien ça coûte pour le club ?
|
||||||
q5_answer_html: "Vous pouvez commencer avec l'offre <strong>Free</strong> (limites sur le staff et un direct à la fois). Premium Light et Full ajoutent plus de matchs en parallèle, l'archive et YouTube. %{pricing_link}."
|
q5_answer_html: "Vous pouvez commencer avec l'offre <strong>Free</strong> (limites sur le staff et un direct à la fois). Premium Light et Full ajoutent plus de matchs en parallèle, l'archive et YouTube. %{pricing_link}."
|
||||||
q5_pricing_link: Comparer les tarifs
|
q5_pricing_link: Comparer les tarifs
|
||||||
@@ -147,6 +159,11 @@ fr:
|
|||||||
q7_answer: "Avec Premium Light, vous pouvez le diffuser sur la chaîne Match Live TV ; avec Premium Full, aussi sur la chaîne YouTube du club. Ceux qui préfèrent peuvent rester sur le lien Match Live TV, pratique pour les familles."
|
q7_answer: "Avec Premium Light, vous pouvez le diffuser sur la chaîne Match Live TV ; avec Premium Full, aussi sur la chaîne YouTube du club. Ceux qui préfèrent peuvent rester sur le lien Match Live TV, pratique pour les familles."
|
||||||
q8_question: Dois-je ouvrir des ports sur le routeur ou avoir du matériel TV ?
|
q8_question: Dois-je ouvrir des ports sur le routeur ou avoir du matériel TV ?
|
||||||
q8_answer: "Non, pas pour les spectateurs. Pour le club qui diffuse, un smartphone et une bonne connexion en salle suffisent ; le staff technique de la plateforme gère l'infrastructure. Pas de caméras de diffusion ni de mixeur."
|
q8_answer: "Non, pas pour les spectateurs. Pour le club qui diffuse, un smartphone et une bonne connexion en salle suffisent ; le staff technique de la plateforme gère l'infrastructure. Pas de caméras de diffusion ni de mixeur."
|
||||||
|
q9_question: Comment fonctionne la garantie Satisfait ou remboursé ?
|
||||||
|
q9_answer_html: "Vous pouvez essayer Match Live TV avec votre club pendant 30 jours à compter de l'activation du premier abonnement. Si dans ce délai la plateforme ne convient pas à vos besoins, vous pouvez demander le remboursement de la totalité du montant payé. Pour plus de détails, consultez les %{terms_link}."
|
||||||
|
q9_terms_link: Conditions générales
|
||||||
|
q10_question: Puis-je afficher les sponsors du club dans les directs ?
|
||||||
|
q10_answer: Avec Premium Full, vous pouvez charger une jaquette personnalisée pour le match. Le club prépare son propre graphisme et peut y intégrer sponsors, partenaires, logos et messages. La jaquette s’affiche avant le début du direct, donnant de la visibilité aux réalités qui soutiennent le club.
|
||||||
cta_signup: Inscrire l'équipe
|
cta_signup: Inscrire l'équipe
|
||||||
cta_live: Voir les directs
|
cta_live: Voir les directs
|
||||||
volleyball:
|
volleyball:
|
||||||
|
|||||||
@@ -59,11 +59,8 @@ it:
|
|||||||
replay_item_download: Download del video sul telefono quando serve
|
replay_item_download: Download del video sul telefono quando serve
|
||||||
replay_card_title: Archivio partite
|
replay_card_title: Archivio partite
|
||||||
replay_card_body: Rivedi le gare dopo il fischio finale e scaricale quando ti servono.
|
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_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_2_meta: "05/05 · 01:18:22"
|
||||||
replay_mock_3_title: Rossi vs Bianchi
|
|
||||||
replay_mock_3_meta: "28/04 · 01:07:15"
|
replay_mock_3_meta: "28/04 · 01:07:15"
|
||||||
more_title: Anche questo fa parte del prodotto
|
more_title: Anche questo fa parte del prodotto
|
||||||
more_stable_title: Diretta dallo smartphone
|
more_stable_title: Diretta dallo smartphone
|
||||||
@@ -76,6 +73,19 @@ it:
|
|||||||
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
|
cta_body: Prova Match Live TV gratuitamente. Non serve attrezzatura dedicata.
|
||||||
cta_primary: Inizia gratis
|
cta_primary: Inizia gratis
|
||||||
cta_secondary: Confronta i piani
|
cta_secondary: Confronta i piani
|
||||||
|
sponsor_cover:
|
||||||
|
eyebrow: Premium Full
|
||||||
|
title: Dai visibilità ai tuoi sponsor
|
||||||
|
body: Con Premium Full puoi personalizzare la copertina di ogni partita caricando una grafica della tua società. Puoi così inserire sponsor, partner, loghi e comunicazioni e dare loro visibilità già prima dell’inizio della diretta.
|
||||||
|
item_cover: Copertina caricata per ogni partita
|
||||||
|
item_before: Grafica visibile nel pre-live
|
||||||
|
item_club: Identità della società, non un template generico
|
||||||
|
claim: Il tuo club. I tuoi sponsor. La tua diretta.
|
||||||
|
cta: Scopri Premium Full
|
||||||
|
mock:
|
||||||
|
state: PRE-LIVE
|
||||||
|
art_label: Copertina personalizzata
|
||||||
|
soon: La diretta inizierà a breve
|
||||||
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."
|
||||||
@@ -88,8 +98,9 @@ it:
|
|||||||
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
|
||||||
table_replay: Replay server
|
table_replay: Archivio replay
|
||||||
table_download: Download telefono
|
table_download: Download telefono
|
||||||
|
table_cover_sponsor: Copertina personalizzata
|
||||||
table_price: Prezzo
|
table_price: Prezzo
|
||||||
table_price_free: "€0"
|
table_price_free: "€0"
|
||||||
table_price_note: "listino %{list} — %{monthly}"
|
table_price_note: "listino %{list} — %{monthly}"
|
||||||
@@ -121,6 +132,7 @@ it:
|
|||||||
youtube_mltv: Match Live TV
|
youtube_mltv: Match Live TV
|
||||||
youtube_club: canale società
|
youtube_club: canale società
|
||||||
youtube_none: "no"
|
youtube_none: "no"
|
||||||
|
cover_sponsor_html: "Copertina personalizzabile <strong>con sponsor</strong>"
|
||||||
complete_billing: Completa dati di fatturazione
|
complete_billing: Completa dati di fatturazione
|
||||||
start_free: Inizia gratis
|
start_free: Inizia gratis
|
||||||
register_with_price: "Registrati — %{price}"
|
register_with_price: "Registrati — %{price}"
|
||||||
@@ -137,7 +149,7 @@ it:
|
|||||||
q3_answer_html: "Sì. Il punteggio a set è pensato per la pallavolo; puoi personalizzare regole per tornei particolari. Scopri la pagina dedicata: %{volleyball_link}."
|
q3_answer_html: "Sì. Il punteggio a set è pensato per la pallavolo; puoi personalizzare regole per tornei particolari. Scopri la pagina dedicata: %{volleyball_link}."
|
||||||
q3_volleyball_link: Match Live TV per pallavolo giovanile
|
q3_volleyball_link: Match Live TV per pallavolo giovanile
|
||||||
q4_question: Cosa succede se perdo la diretta?
|
q4_question: Cosa succede se perdo la diretta?
|
||||||
q4_answer: "Con i piani Premium Light o Full la partita viene salvata in archivio (30 o 90 giorni) e puoi rivederla dal sito. Il piano Free non include replay su server, ma la diretta resta gratuita per chi trasmette e per chi guarda."
|
q4_answer: "Con i piani Premium Light o Full la partita viene salvata in archivio (30 o 90 giorni) e puoi rivederla dal sito. Il piano Free non include l'archivio replay, ma la diretta resta gratuita per chi trasmette e per chi guarda."
|
||||||
q5_question: Quanto costa per la società?
|
q5_question: Quanto costa per la società?
|
||||||
q5_answer_html: "Puoi iniziare con il piano <strong>Free</strong> (limiti su staff e una diretta alla volta). Premium Light e Full aggiungono più partite in parallelo, archivio e YouTube. %{pricing_link}."
|
q5_answer_html: "Puoi iniziare con il piano <strong>Free</strong> (limiti su staff e una diretta alla volta). Premium Light e Full aggiungono più partite in parallelo, archivio e YouTube. %{pricing_link}."
|
||||||
q5_pricing_link: Confronta i prezzi
|
q5_pricing_link: Confronta i prezzi
|
||||||
@@ -147,6 +159,11 @@ it:
|
|||||||
q7_answer: "Con Premium Light puoi mandarla sul canale Match Live TV; con Premium Full anche sul canale YouTube della società. Chi preferisce resta sul link Match Live TV, comodo per le famiglie."
|
q7_answer: "Con Premium Light puoi mandarla sul canale Match Live TV; con Premium Full anche sul canale YouTube della società. Chi preferisce resta sul link Match Live TV, comodo per le famiglie."
|
||||||
q8_question: Devo aprire porte sul router o avere attrezzatura da TV?
|
q8_question: Devo aprire porte sul router o avere attrezzatura da TV?
|
||||||
q8_answer: "No per chi guarda. Per la società che trasmette basta lo smartphone e una buona connessione in palestra; lo staff tecnico della piattaforma gestisce l'infrastruttura. Niente camere broadcast o mixer."
|
q8_answer: "No per chi guarda. Per la società che trasmette basta lo smartphone e una buona connessione in palestra; lo staff tecnico della piattaforma gestisce l'infrastruttura. Niente camere broadcast o mixer."
|
||||||
|
q9_question: Come funziona la garanzia Soddisfatti o rimborsati?
|
||||||
|
q9_answer_html: "Puoi provare Match Live TV con la tua società per 30 giorni dall'attivazione del primo abbonamento. Se entro questo periodo ritieni che la piattaforma non sia adatta alle tue esigenze, puoi richiedere il rimborso dell'intero importo pagato. Per maggiori dettagli consulta i %{terms_link}."
|
||||||
|
q9_terms_link: Termini e Condizioni
|
||||||
|
q10_question: Posso inserire gli sponsor della società nelle dirette?
|
||||||
|
q10_answer: Con Premium Full puoi caricare una copertina personalizzata per la partita. La società può preparare la propria grafica inserendo sponsor, partner, loghi e comunicazioni. La copertina viene mostrata prima dell’inizio della diretta, permettendo di dare visibilità alle realtà che sostengono il club.
|
||||||
cta_signup: Registra la squadra
|
cta_signup: Registra la squadra
|
||||||
cta_live: Guarda le dirette
|
cta_live: Guarda le dirette
|
||||||
volleyball:
|
volleyball:
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ de:
|
|||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
account: Konto
|
account: Konto
|
||||||
signup: Team registrieren
|
signup: Team registrieren
|
||||||
|
analytics_preview:
|
||||||
|
badge: Staff-Vorschau — Analytics deaktiviert
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||||
live: Live
|
live: Live
|
||||||
@@ -81,6 +84,16 @@ de:
|
|||||||
seo_faq_link: FAQ
|
seo_faq_link: FAQ
|
||||||
seo_volleyball_link: Match Live TV für Jugendvolleyball
|
seo_volleyball_link: Match Live TV für Jugendvolleyball
|
||||||
seo_signup_link: Team kostenlos registrieren
|
seo_signup_link: Team kostenlos registrieren
|
||||||
|
guarantee:
|
||||||
|
title: Zufrieden oder Geld zurück
|
||||||
|
kicker_days: 30 Tage
|
||||||
|
subtitle: 30 Tage, um Match Live TV ohne Risiko zu testen.
|
||||||
|
body: Wenn Match Live TV nicht die richtige Lösung für Ihren Verein ist, erstatten wir 100 % des Abos.
|
||||||
|
compact: 30 Tage zufrieden oder Geld zurück
|
||||||
|
home_note: Zufrieden oder Geld zurück innerhalb von 30 Tagen
|
||||||
|
checkout: Ihr Kauf ist durch die 30-Tage-Garantie „Zufrieden oder Geld zurück“ abgedeckt.
|
||||||
|
learn_more: So funktioniert es
|
||||||
|
aria_pricing: Garantie Zufrieden oder Geld zurück
|
||||||
auth:
|
auth:
|
||||||
email: E-Mail
|
email: E-Mail
|
||||||
password: Passwort
|
password: Passwort
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ en:
|
|||||||
logout: Log out
|
logout: Log out
|
||||||
account: Account
|
account: Account
|
||||||
signup: Register a team
|
signup: Register a team
|
||||||
|
analytics_preview:
|
||||||
|
badge: Staff preview — analytics disabled
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Every match, every event, for those who can't be there
|
tagline: Every match, every event, for those who can't be there
|
||||||
live: Live
|
live: Live
|
||||||
@@ -81,6 +84,16 @@ en:
|
|||||||
seo_faq_link: FAQ
|
seo_faq_link: FAQ
|
||||||
seo_volleyball_link: Match Live TV for youth volleyball
|
seo_volleyball_link: Match Live TV for youth volleyball
|
||||||
seo_signup_link: register your team for free
|
seo_signup_link: register your team for free
|
||||||
|
guarantee:
|
||||||
|
title: Satisfied or refunded
|
||||||
|
kicker_days: 30 days
|
||||||
|
subtitle: 30 days to try Match Live TV with no risk.
|
||||||
|
body: If Match Live TV is not the right fit for your club, we refund 100% of the subscription.
|
||||||
|
compact: 30-day satisfied or refunded
|
||||||
|
home_note: Satisfied or refunded within 30 days
|
||||||
|
checkout: Your purchase is covered by the 30-day Satisfied or refunded guarantee.
|
||||||
|
learn_more: See how it works
|
||||||
|
aria_pricing: Satisfied or refunded guarantee
|
||||||
auth:
|
auth:
|
||||||
email: Email
|
email: Email
|
||||||
password: Password
|
password: Password
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ es:
|
|||||||
logout: Salir
|
logout: Salir
|
||||||
account: Cuenta
|
account: Cuenta
|
||||||
signup: Registrar equipo
|
signup: Registrar equipo
|
||||||
|
analytics_preview:
|
||||||
|
badge: Vista previa staff — analytics desactivados
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Cada partido, cada evento, para quien no puede estar
|
tagline: Cada partido, cada evento, para quien no puede estar
|
||||||
live: Directos
|
live: Directos
|
||||||
@@ -81,6 +84,16 @@ es:
|
|||||||
seo_faq_link: preguntas frecuentes
|
seo_faq_link: preguntas frecuentes
|
||||||
seo_volleyball_link: Match Live TV para voleibol juvenil
|
seo_volleyball_link: Match Live TV para voleibol juvenil
|
||||||
seo_signup_link: registra el equipo gratis
|
seo_signup_link: registra el equipo gratis
|
||||||
|
guarantee:
|
||||||
|
title: Satisfechos o reembolsados
|
||||||
|
kicker_days: 30 días
|
||||||
|
subtitle: 30 días para probar Match Live TV sin riesgos.
|
||||||
|
body: Si Match Live TV no es la solución adecuada para tu club, te reembolsamos el 100% de la suscripción.
|
||||||
|
compact: 30 días satisfechos o reembolsados
|
||||||
|
home_note: Satisfechos o reembolsados en 30 días
|
||||||
|
checkout: Tu compra está cubierta por la garantía Satisfechos o reembolsados de 30 días.
|
||||||
|
learn_more: Cómo funciona
|
||||||
|
aria_pricing: Garantía satisfechos o reembolsados
|
||||||
auth:
|
auth:
|
||||||
email: Email
|
email: Email
|
||||||
password: Contraseña
|
password: Contraseña
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ fr:
|
|||||||
logout: Déconnexion
|
logout: Déconnexion
|
||||||
account: Compte
|
account: Compte
|
||||||
signup: Inscrire une équipe
|
signup: Inscrire une équipe
|
||||||
|
analytics_preview:
|
||||||
|
badge: Aperçu staff — analytics désactivés
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||||
live: Directs
|
live: Directs
|
||||||
@@ -81,6 +84,16 @@ fr:
|
|||||||
seo_faq_link: FAQ
|
seo_faq_link: FAQ
|
||||||
seo_volleyball_link: Match Live TV pour le volley jeunes
|
seo_volleyball_link: Match Live TV pour le volley jeunes
|
||||||
seo_signup_link: inscrivez l'équipe gratuitement
|
seo_signup_link: inscrivez l'équipe gratuitement
|
||||||
|
guarantee:
|
||||||
|
title: Satisfait ou remboursé
|
||||||
|
kicker_days: 30 jours
|
||||||
|
subtitle: 30 jours pour essayer Match Live TV sans risque.
|
||||||
|
body: Si Match Live TV n'est pas la bonne solution pour votre club, nous remboursons 100 % de l'abonnement.
|
||||||
|
compact: 30 jours satisfait ou remboursé
|
||||||
|
home_note: Satisfait ou remboursé sous 30 jours
|
||||||
|
checkout: Votre achat est couvert par la garantie Satisfait ou remboursé de 30 jours.
|
||||||
|
learn_more: Voir comment ça fonctionne
|
||||||
|
aria_pricing: Garantie satisfait ou remboursé
|
||||||
auth:
|
auth:
|
||||||
email: E-mail
|
email: E-mail
|
||||||
password: Mot de passe
|
password: Mot de passe
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ it:
|
|||||||
logout: Esci
|
logout: Esci
|
||||||
account: Account
|
account: Account
|
||||||
signup: Registra squadra
|
signup: Registra squadra
|
||||||
|
analytics_preview:
|
||||||
|
badge: Anteprima staff — analytics disattivati
|
||||||
|
manage: Admin
|
||||||
footer:
|
footer:
|
||||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||||
live: Dirette
|
live: Dirette
|
||||||
@@ -81,6 +84,16 @@ it:
|
|||||||
seo_faq_link: domande frequenti
|
seo_faq_link: domande frequenti
|
||||||
seo_volleyball_link: Match Live TV per la pallavolo giovanile
|
seo_volleyball_link: Match Live TV per la pallavolo giovanile
|
||||||
seo_signup_link: registra la squadra gratis
|
seo_signup_link: registra la squadra gratis
|
||||||
|
guarantee:
|
||||||
|
title: Soddisfatti o rimborsati
|
||||||
|
kicker_days: 30 giorni
|
||||||
|
subtitle: 30 giorni per provare Match Live TV senza rischi.
|
||||||
|
body: Se Match Live TV non è la soluzione giusta per la tua società, ti rimborsiamo il 100% dell'abbonamento.
|
||||||
|
compact: 30 giorni soddisfatti o rimborsati
|
||||||
|
home_note: Soddisfatti o rimborsati entro 30 giorni
|
||||||
|
checkout: Il tuo acquisto è coperto dalla garanzia Soddisfatti o rimborsati di 30 giorni.
|
||||||
|
learn_more: Scopri come funziona
|
||||||
|
aria_pricing: Garanzia soddisfatti o rimborsati
|
||||||
auth:
|
auth:
|
||||||
email: Email
|
email: Email
|
||||||
password: Password
|
password: Password
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ Rails.application.routes.draw do
|
|||||||
post :regia_link
|
post :regia_link
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
resources :stream_concurrency_violations, only: %i[index]
|
||||||
resources :stream_nodes, only: %i[index create destroy] do
|
resources :stream_nodes, only: %i[index create destroy] do
|
||||||
member do
|
member do
|
||||||
post :drain
|
post :drain
|
||||||
@@ -130,8 +131,12 @@ Rails.application.routes.draw do
|
|||||||
delete :clear_kill_switch
|
delete :clear_kill_switch
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
get "costs", to: "costs#index", as: :costs
|
||||||
|
resources :cost_entries, path: "costs/entries", except: %i[index show]
|
||||||
get "analytics", to: "analytics#index", as: :analytics
|
get "analytics", to: "analytics#index", as: :analytics
|
||||||
get "analytics/page", to: "analytics#show", as: :analytics_page
|
get "analytics/page", to: "analytics#show", as: :analytics_page
|
||||||
|
post "analytics/preview", to: "analytics#preview_enable", as: :analytics_preview
|
||||||
|
delete "analytics/preview", to: "analytics#preview_disable"
|
||||||
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
get "youtube/platform", to: "youtube#platform", as: :youtube_platform
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreatePlatformCostEntries < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :platform_cost_entries, id: :uuid do |t|
|
||||||
|
t.date :month, null: false
|
||||||
|
t.string :label, null: false, default: "Piattaforma produzione"
|
||||||
|
t.integer :amount_cents, null: false
|
||||||
|
t.text :notes
|
||||||
|
|
||||||
|
t.timestamps
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :platform_cost_entries, :month
|
||||||
|
add_index :platform_cost_entries, %i[month label]
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
class CreateStreamConcurrencyViolations < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
create_table :stream_concurrency_violations, id: :uuid, default: -> { "gen_random_uuid()" } do |t|
|
||||||
|
t.references :user, type: :uuid, foreign_key: { on_delete: :nullify }
|
||||||
|
t.references :occupying_session, type: :uuid, foreign_key: { to_table: :stream_sessions, on_delete: :nullify }
|
||||||
|
t.references :attempted_session, type: :uuid, foreign_key: { to_table: :stream_sessions, on_delete: :nullify }
|
||||||
|
t.references :occupying_club, type: :uuid, foreign_key: { to_table: :clubs, on_delete: :nullify }
|
||||||
|
t.references :attempted_club, type: :uuid, foreign_key: { to_table: :clubs, on_delete: :nullify }
|
||||||
|
|
||||||
|
t.string :attempt_action, null: false, default: "start"
|
||||||
|
t.string :user_email, null: false
|
||||||
|
t.string :user_name
|
||||||
|
t.string :occupying_club_name
|
||||||
|
t.string :attempted_club_name
|
||||||
|
t.string :occupying_match_label, null: false
|
||||||
|
t.string :attempted_match_label, null: false
|
||||||
|
t.string :occupying_status
|
||||||
|
t.string :occupying_device
|
||||||
|
t.string :attempted_device
|
||||||
|
t.boolean :devices_differ, null: false, default: false
|
||||||
|
t.jsonb :metadata, null: false, default: {}
|
||||||
|
|
||||||
|
t.datetime :created_at, null: false
|
||||||
|
end
|
||||||
|
|
||||||
|
add_index :stream_concurrency_violations, :created_at
|
||||||
|
add_index :stream_concurrency_violations, :devices_differ
|
||||||
|
add_index :stream_sessions, %i[user_id status], name: "index_stream_sessions_on_user_id_and_status"
|
||||||
|
end
|
||||||
|
end
|
||||||
Generated
+47
-2
@@ -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_28_124000) do
|
ActiveRecord::Schema[7.2].define(version: 2026_08_31_193000) 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"
|
||||||
@@ -313,6 +313,17 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
|
|||||||
t.index ["slug"], name: "index_plans_on_slug", unique: true
|
t.index ["slug"], name: "index_plans_on_slug", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "platform_cost_entries", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.date "month", null: false
|
||||||
|
t.string "label", default: "Piattaforma produzione", null: false
|
||||||
|
t.integer "amount_cents", null: false
|
||||||
|
t.text "notes"
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.datetime "updated_at", null: false
|
||||||
|
t.index ["month", "label"], name: "index_platform_cost_entries_on_month_and_label"
|
||||||
|
t.index ["month"], name: "index_platform_cost_entries_on_month"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "recordings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "stream_session_id", null: false
|
t.uuid "stream_session_id", null: false
|
||||||
t.uuid "team_id", null: false
|
t.uuid "team_id", null: false
|
||||||
@@ -372,6 +383,34 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
|
|||||||
t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true
|
t.index ["stream_session_id"], name: "index_score_states_on_stream_session_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
create_table "stream_concurrency_violations", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
|
t.uuid "user_id"
|
||||||
|
t.uuid "occupying_session_id"
|
||||||
|
t.uuid "attempted_session_id"
|
||||||
|
t.uuid "occupying_club_id"
|
||||||
|
t.uuid "attempted_club_id"
|
||||||
|
t.string "attempt_action", default: "start", null: false
|
||||||
|
t.string "user_email", null: false
|
||||||
|
t.string "user_name"
|
||||||
|
t.string "occupying_club_name"
|
||||||
|
t.string "attempted_club_name"
|
||||||
|
t.string "occupying_match_label", null: false
|
||||||
|
t.string "attempted_match_label", null: false
|
||||||
|
t.string "occupying_status"
|
||||||
|
t.string "occupying_device"
|
||||||
|
t.string "attempted_device"
|
||||||
|
t.boolean "devices_differ", default: false, null: false
|
||||||
|
t.jsonb "metadata", default: {}, null: false
|
||||||
|
t.datetime "created_at", null: false
|
||||||
|
t.index ["attempted_club_id"], name: "index_stream_concurrency_violations_on_attempted_club_id"
|
||||||
|
t.index ["attempted_session_id"], name: "index_stream_concurrency_violations_on_attempted_session_id"
|
||||||
|
t.index ["created_at"], name: "index_stream_concurrency_violations_on_created_at"
|
||||||
|
t.index ["devices_differ"], name: "index_stream_concurrency_violations_on_devices_differ"
|
||||||
|
t.index ["occupying_club_id"], name: "index_stream_concurrency_violations_on_occupying_club_id"
|
||||||
|
t.index ["occupying_session_id"], name: "index_stream_concurrency_violations_on_occupying_session_id"
|
||||||
|
t.index ["user_id"], name: "index_stream_concurrency_violations_on_user_id"
|
||||||
|
end
|
||||||
|
|
||||||
create_table "stream_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
create_table "stream_events", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "stream_session_id", null: false
|
t.uuid "stream_session_id", null: false
|
||||||
t.string "event_type", null: false
|
t.string "event_type", null: false
|
||||||
@@ -429,9 +468,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
|
|||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.string "regia_token_digest"
|
t.string "regia_token_digest"
|
||||||
t.datetime "regia_token_expires_at"
|
t.datetime "regia_token_expires_at"
|
||||||
|
t.boolean "audio_muted", default: false, null: false
|
||||||
t.uuid "stream_node_id"
|
t.uuid "stream_node_id"
|
||||||
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.string "client_os"
|
t.string "client_os"
|
||||||
t.string "app_version"
|
t.string "app_version"
|
||||||
t.string "app_build"
|
t.string "app_build"
|
||||||
@@ -447,6 +486,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
|
|||||||
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
|
||||||
t.index ["status"], name: "index_stream_sessions_on_status"
|
t.index ["status"], name: "index_stream_sessions_on_status"
|
||||||
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
t.index ["stream_node_id"], name: "index_stream_sessions_on_stream_node_id"
|
||||||
|
t.index ["user_id", "status"], name: "index_stream_sessions_on_user_id_and_status"
|
||||||
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
t.index ["user_id"], name: "index_stream_sessions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -580,6 +620,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_28_124000) do
|
|||||||
add_foreign_key "recordings", "stream_sessions"
|
add_foreign_key "recordings", "stream_sessions"
|
||||||
add_foreign_key "recordings", "teams"
|
add_foreign_key "recordings", "teams"
|
||||||
add_foreign_key "score_states", "stream_sessions"
|
add_foreign_key "score_states", "stream_sessions"
|
||||||
|
add_foreign_key "stream_concurrency_violations", "clubs", column: "attempted_club_id", on_delete: :nullify
|
||||||
|
add_foreign_key "stream_concurrency_violations", "clubs", column: "occupying_club_id", on_delete: :nullify
|
||||||
|
add_foreign_key "stream_concurrency_violations", "stream_sessions", column: "attempted_session_id", on_delete: :nullify
|
||||||
|
add_foreign_key "stream_concurrency_violations", "stream_sessions", column: "occupying_session_id", on_delete: :nullify
|
||||||
|
add_foreign_key "stream_concurrency_violations", "users", on_delete: :nullify
|
||||||
add_foreign_key "stream_events", "stream_sessions"
|
add_foreign_key "stream_events", "stream_sessions"
|
||||||
add_foreign_key "stream_sessions", "matches"
|
add_foreign_key "stream_sessions", "matches"
|
||||||
add_foreign_key "stream_sessions", "stream_nodes"
|
add_foreign_key "stream_sessions", "stream_nodes"
|
||||||
|
|||||||
+5
-5
@@ -16,7 +16,7 @@ admin = User.find_or_create_by!(email: "admin@matchlivetv.test") do |u|
|
|||||||
u.role = "admin"
|
u.role = "admin"
|
||||||
end
|
end
|
||||||
|
|
||||||
club = Club.find_or_create_by!(name: "Tigers Volley") do |c|
|
club = Club.find_or_create_by!(name: MatchLiveTv::Demo.home_team) do |c|
|
||||||
c.sport = "volleyball"
|
c.sport = "volleyball"
|
||||||
c.primary_color = "#e53935"
|
c.primary_color = "#e53935"
|
||||||
c.secondary_color = "#ffffff"
|
c.secondary_color = "#ffffff"
|
||||||
@@ -24,7 +24,7 @@ end
|
|||||||
|
|
||||||
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
|
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
|
||||||
|
|
||||||
team = club.teams.find_or_create_by!(name: "Under 16") do |t|
|
team = club.teams.find_or_create_by!(name: MatchLiveTv::Demo::CATEGORY) do |t|
|
||||||
t.sport = "volleyball"
|
t.sport = "volleyball"
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -32,12 +32,12 @@ UserTeam.find_or_create_by!(user: admin, team: team) { |ut| ut.role = "member" }
|
|||||||
|
|
||||||
Billing::AssignPlan.call(club: club, plan_slug: "free") unless club.subscription
|
Billing::AssignPlan.call(club: club, plan_slug: "free") unless club.subscription
|
||||||
|
|
||||||
match = team.matches.find_or_create_by!(opponent_name: "ASD Eagles Pavia") do |m|
|
match = team.matches.find_or_create_by!(opponent_name: MatchLiveTv::Demo::AWAY_TEAM) do |m|
|
||||||
m.location = "PalaTigers - Milano"
|
m.location = MatchLiveTv::Demo.venue
|
||||||
m.scheduled_at = 2.hours.from_now
|
m.scheduled_at = 2.hours.from_now
|
||||||
m.sets_to_win = 3
|
m.sets_to_win = 3
|
||||||
m.roster_numbers = [4, 6, 8, 9, 10, 11, 12, 14, 16, 17, 21]
|
m.roster_numbers = [4, 6, 8, 9, 10, 11, 12, 14, 16, 17, 21]
|
||||||
m.category = "U16"
|
m.category = MatchLiveTv::Demo::CATEGORY
|
||||||
m.phase = "Semifinale"
|
m.phase = "Semifinale"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
# Dati simulati per la pagina Admin → Costi (trend 12 mesi, società, revenue).
|
||||||
|
# Uso: bundle exec rails runner db/seeds/cost_analytics_demo.rb
|
||||||
|
|
||||||
|
load Rails.root.join("db/seeds/plans.rb")
|
||||||
|
|
||||||
|
coach = User.find_or_create_by!(email: "coach@matchlivetv.test") do |u|
|
||||||
|
u.name = "Coach Demo"
|
||||||
|
u.password = "Password123"
|
||||||
|
u.role = "coach"
|
||||||
|
end
|
||||||
|
|
||||||
|
clubs_data = [
|
||||||
|
{ name: MatchLiveTv::Demo::HOME_TEAM, sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 },
|
||||||
|
{ name: "Team MLTV Nord", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 },
|
||||||
|
{ name: "Team MLTV Sud", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 },
|
||||||
|
{ name: "Team MLTV Basket", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 },
|
||||||
|
{ name: MatchLiveTv::Demo::AWAY_TEAM, sport: "volleyball", plan: "free", sessions: 2, hours: 2.0, revenue_yearly: 0 }
|
||||||
|
]
|
||||||
|
|
||||||
|
sport_keys = {
|
||||||
|
"volleyball" => "pallavolo",
|
||||||
|
"basketball" => "basket"
|
||||||
|
}
|
||||||
|
|
||||||
|
clubs = clubs_data.map do |row|
|
||||||
|
club = Club.find_or_create_by!(name: row[:name]) { |c| c.sport = row[:sport] }
|
||||||
|
Billing::AssignPlan.call(club: club, plan_slug: row[:plan])
|
||||||
|
ClubMembership.find_or_create_by!(user: coach, club: club) { |m| m.role = "owner" }
|
||||||
|
team = club.teams.find_or_create_by!(name: "Prima squadra") do |t|
|
||||||
|
t.sport_key = sport_keys.fetch(row[:sport], "pallavolo")
|
||||||
|
end
|
||||||
|
{ club: club, team: team, **row }
|
||||||
|
end
|
||||||
|
|
||||||
|
month_costs = [
|
||||||
|
{ offset: 11, hetzner: 89.00, stream: 12.50, s3: 8.20 },
|
||||||
|
{ offset: 10, hetzner: 89.00, stream: 18.00, s3: 9.10 },
|
||||||
|
{ offset: 9, hetzner: 89.00, stream: 24.50, s3: 10.40 },
|
||||||
|
{ offset: 8, hetzner: 89.00, stream: 31.00, s3: 11.80 },
|
||||||
|
{ offset: 7, hetzner: 89.00, stream: 22.00, s3: 12.20 },
|
||||||
|
{ offset: 6, hetzner: 89.00, stream: 15.50, s3: 13.50 },
|
||||||
|
{ offset: 5, hetzner: 89.00, stream: 28.00, s3: 14.80 },
|
||||||
|
{ offset: 4, hetzner: 89.00, stream: 35.50, s3: 15.60 },
|
||||||
|
{ offset: 3, hetzner: 89.00, stream: 42.00, s3: 16.90 },
|
||||||
|
{ offset: 2, hetzner: 89.00, stream: 38.00, s3: 18.20 },
|
||||||
|
{ offset: 1, hetzner: 89.00, stream: 45.00, s3: 19.50 },
|
||||||
|
{ offset: 0, hetzner: 89.00, stream: 52.00, s3: 21.00 }
|
||||||
|
]
|
||||||
|
|
||||||
|
PlatformCostEntry.delete_all
|
||||||
|
|
||||||
|
month_costs.each do |row|
|
||||||
|
month = Time.zone.today.beginning_of_month - row[:offset].months
|
||||||
|
[
|
||||||
|
{ label: "Hetzner home + backup", amount: row[:hetzner] },
|
||||||
|
{ label: "Nodi stream CPX (overflow)", amount: row[:stream] },
|
||||||
|
{ label: "Garage/S3 replay storage", amount: row[:s3] }
|
||||||
|
].each do |entry|
|
||||||
|
PlatformCostEntry.create!(
|
||||||
|
month: month,
|
||||||
|
label: entry[:label],
|
||||||
|
amount_cents: (entry[:amount] * 100).round,
|
||||||
|
notes: "Dato demo seed"
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sessioni e pagamenti nel mese corrente (distribuiti tra società)
|
||||||
|
current_month = Time.zone.today.beginning_of_month
|
||||||
|
clubs.each_with_index do |row, index|
|
||||||
|
club = row[:club]
|
||||||
|
team = row[:team]
|
||||||
|
sessions_count = row[:sessions]
|
||||||
|
total_secs = (row[:hours] * 3600).round
|
||||||
|
|
||||||
|
sessions_count.times do |n|
|
||||||
|
secs = (total_secs / sessions_count.to_f).round
|
||||||
|
day = current_month + (n % 27).days
|
||||||
|
started = day.change(hour: 10 + (n % 6), min: 0)
|
||||||
|
ended = started + secs.seconds
|
||||||
|
match = team.matches.create!(
|
||||||
|
opponent_name: MatchLiveTv::Demo::AWAY_TEAM,
|
||||||
|
sport_key: team.sport_key,
|
||||||
|
scheduled_at: started
|
||||||
|
)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: coach,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "ended",
|
||||||
|
started_at: started,
|
||||||
|
ended_at: ended,
|
||||||
|
total_duration_secs: secs
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
if row[:revenue_yearly].positive?
|
||||||
|
payment = Billing::Payment.find_or_initialize_by(club: club, paid_at: current_month + (index + 3).days)
|
||||||
|
payment.assign_attributes(
|
||||||
|
status: "paid",
|
||||||
|
provider: "stripe",
|
||||||
|
amount_cents: row[:revenue_yearly],
|
||||||
|
currency: "EUR",
|
||||||
|
plan_slug: row[:plan]
|
||||||
|
)
|
||||||
|
payment.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
# Storage simulato (cumulativo)
|
||||||
|
sample_session = team.matches.last&.stream_sessions&.first
|
||||||
|
next unless sample_session
|
||||||
|
|
||||||
|
rec = Recording.find_or_initialize_by(stream_session: sample_session)
|
||||||
|
rec.assign_attributes(
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
privacy_status: "unlisted",
|
||||||
|
storage_backend: "s3",
|
||||||
|
storage_policy: "retained",
|
||||||
|
byte_size: (500_000_000 + index * 180_000_000),
|
||||||
|
duration_secs: 3600
|
||||||
|
)
|
||||||
|
rec.save!
|
||||||
|
end
|
||||||
|
|
||||||
|
puts "Cost analytics demo OK:"
|
||||||
|
puts " Voci costo: #{PlatformCostEntry.count} (12 mesi)"
|
||||||
|
puts " Sessioni demo mese corrente: #{StreamSession.where(status: 'ended', ended_at: current_month..current_month.end_of_month).count}"
|
||||||
|
puts " Admin: http://localhost:3000/admin/costs"
|
||||||
|
puts " Login admin: admin / AdminPass123"
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
module MatchLiveTv
|
||||||
|
# Set demo ufficiale per mockup, preview e contenuti dimostrativi del sito.
|
||||||
|
# Nomi fittizi, chiaramente riconducibili a MatchLiveTV — mai società o sponsor reali.
|
||||||
|
module Demo
|
||||||
|
HOME_TEAM = "Team MLTV"
|
||||||
|
AWAY_TEAM = "Team Guest"
|
||||||
|
CATEGORY = "U15 MASCHILE"
|
||||||
|
VENUE = "Pala MLTV"
|
||||||
|
|
||||||
|
module_function
|
||||||
|
|
||||||
|
def home_team
|
||||||
|
HOME_TEAM
|
||||||
|
end
|
||||||
|
|
||||||
|
def away_team
|
||||||
|
I18n.t("demo.away_team", default: AWAY_TEAM)
|
||||||
|
end
|
||||||
|
|
||||||
|
def category
|
||||||
|
CATEGORY
|
||||||
|
end
|
||||||
|
|
||||||
|
def when_label
|
||||||
|
I18n.t("demo.when")
|
||||||
|
end
|
||||||
|
|
||||||
|
def venue
|
||||||
|
VENUE
|
||||||
|
end
|
||||||
|
|
||||||
|
def match_title
|
||||||
|
"#{home_team} vs #{away_team}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def live_meta
|
||||||
|
"#{VENUE} · Match Live TV"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
(function () {
|
||||||
|
var trend = window.adminCostTrend || [];
|
||||||
|
var i18n = window.adminCostI18n || {};
|
||||||
|
if (!trend.length || typeof Chart === "undefined") return;
|
||||||
|
|
||||||
|
function monthLabel(monthStr) {
|
||||||
|
var parts = monthStr.split("-");
|
||||||
|
if (parts.length < 2) return monthStr;
|
||||||
|
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, 1);
|
||||||
|
return d.toLocaleDateString([], { month: "short", year: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
var labels = trend.map(function (row) {
|
||||||
|
return monthLabel(row.month);
|
||||||
|
});
|
||||||
|
|
||||||
|
var chartOptions = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
animation: { duration: 300 },
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
ticks: { maxTicksLimit: 12, color: "#9a9aad", font: { size: 10 } },
|
||||||
|
grid: { color: "rgba(255,255,255,0.06)" }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
ticks: { color: "#9a9aad", font: { size: 10 } },
|
||||||
|
grid: { color: "rgba(255,255,255,0.06)" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: { legend: { labels: { color: "#ccc", boxWidth: 12 } } }
|
||||||
|
};
|
||||||
|
|
||||||
|
var euroTicks = {
|
||||||
|
ticks: {
|
||||||
|
color: "#9a9aad",
|
||||||
|
font: { size: 10 },
|
||||||
|
callback: function (v) {
|
||||||
|
return "€" + v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
var costRevenueCanvas = document.getElementById("chart-cost-revenue");
|
||||||
|
if (costRevenueCanvas) {
|
||||||
|
new Chart(costRevenueCanvas, {
|
||||||
|
type: "bar",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: i18n.cost || "Cost",
|
||||||
|
data: trend.map(function (r) { return (r.cost_cents || 0) / 100; }),
|
||||||
|
backgroundColor: "rgba(229, 57, 53, 0.65)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.revenue || "Revenue",
|
||||||
|
data: trend.map(function (r) { return (r.revenue_cents || 0) / 100; }),
|
||||||
|
backgroundColor: "rgba(67, 160, 71, 0.65)"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: i18n.margin || "Margin",
|
||||||
|
data: trend.map(function (r) { return (r.margin_cents || 0) / 100; }),
|
||||||
|
backgroundColor: "rgba(255, 193, 7, 0.55)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
scales: {
|
||||||
|
x: chartOptions.scales.x,
|
||||||
|
y: { ...chartOptions.scales.y, ...euroTicks }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var costHourCanvas = document.getElementById("chart-cost-hour");
|
||||||
|
if (costHourCanvas) {
|
||||||
|
new Chart(costHourCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: i18n.costPerHour || "Cost/hour",
|
||||||
|
data: trend.map(function (r) {
|
||||||
|
return r.cost_per_hour_cents ? r.cost_per_hour_cents / 100 : 0;
|
||||||
|
}),
|
||||||
|
borderColor: "#e53935",
|
||||||
|
backgroundColor: "rgba(229, 57, 53, 0.12)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 2,
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
scales: {
|
||||||
|
x: chartOptions.scales.x,
|
||||||
|
y: { ...chartOptions.scales.y, ...euroTicks }
|
||||||
|
},
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var hoursCanvas = document.getElementById("chart-hours");
|
||||||
|
if (hoursCanvas) {
|
||||||
|
new Chart(hoursCanvas, {
|
||||||
|
type: "line",
|
||||||
|
data: {
|
||||||
|
labels: labels,
|
||||||
|
datasets: [{
|
||||||
|
label: i18n.hours || "Hours",
|
||||||
|
data: trend.map(function (r) { return r.hours || 0; }),
|
||||||
|
borderColor: "#43a047",
|
||||||
|
backgroundColor: "rgba(67, 160, 71, 0.12)",
|
||||||
|
fill: true,
|
||||||
|
tension: 0.35,
|
||||||
|
pointRadius: 2,
|
||||||
|
borderWidth: 2
|
||||||
|
}]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
...chartOptions,
|
||||||
|
plugins: { legend: { display: false } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -236,6 +236,11 @@ body.admin-body {
|
|||||||
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
|
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
|
||||||
.badge--ended { background: #37474f; color: #eceff1; }
|
.badge--ended { background: #37474f; color: #eceff1; }
|
||||||
.badge--error { background: #b71c1c; color: #ffebee; }
|
.badge--error { background: #b71c1c; color: #ffebee; }
|
||||||
|
.badge--abuse { background: #b71c1c; color: #ffebee; }
|
||||||
|
|
||||||
|
.admin-table tr.admin-row--two-devices td {
|
||||||
|
background: rgba(183, 28, 28, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-ingest {
|
.admin-ingest {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -693,6 +698,13 @@ body.admin-body {
|
|||||||
background: #3a1515;
|
background: #3a1515;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-device-tab-count {
|
||||||
|
margin-left: 0.35rem;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
.admin-locale-panel {
|
.admin-locale-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -793,6 +805,12 @@ body.admin-body {
|
|||||||
gap: 0.6rem;
|
gap: 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-analytics-preview__status {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
color: #a5f0b8;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-filter-count {
|
.admin-filter-count {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,11 @@
|
|||||||
return document.body && document.body.getAttribute("data-ga-id");
|
return document.body && document.body.getAttribute("data-ga-id");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnalyticsSuppressed() {
|
||||||
|
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
|
||||||
|
return meta && meta.getAttribute("content") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
function loadGoogleAnalytics() {
|
function loadGoogleAnalytics() {
|
||||||
var id = measurementId();
|
var id = measurementId();
|
||||||
if (!id || window.__mltvGaLoaded) return;
|
if (!id || window.__mltvGaLoaded) return;
|
||||||
@@ -68,6 +73,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function applyConsent(consent) {
|
function applyConsent(consent) {
|
||||||
|
if (isAnalyticsSuppressed()) return;
|
||||||
if (consent && consent.analytics) {
|
if (consent && consent.analytics) {
|
||||||
loadGoogleAnalytics();
|
loadGoogleAnalytics();
|
||||||
if (typeof window.mltvSiteAnalyticsStart === "function") {
|
if (typeof window.mltvSiteAnalyticsStart === "function") {
|
||||||
@@ -94,6 +100,10 @@
|
|||||||
|
|
||||||
if (acceptAll) {
|
if (acceptAll) {
|
||||||
acceptAll.addEventListener("click", function () {
|
acceptAll.addEventListener("click", function () {
|
||||||
|
if (isAnalyticsSuppressed()) {
|
||||||
|
hideBanner(banner);
|
||||||
|
return;
|
||||||
|
}
|
||||||
var c = writeConsent({ analytics: true });
|
var c = writeConsent({ analytics: true });
|
||||||
applyConsent(c);
|
applyConsent(c);
|
||||||
hideBanner(banner);
|
hideBanner(banner);
|
||||||
|
|||||||
@@ -1623,9 +1623,14 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
}
|
}
|
||||||
.plan-card > .btn,
|
.plan-card > .btn,
|
||||||
.plan-card > .plan-action-hint,
|
.plan-card > .plan-action-hint,
|
||||||
.plan-card > .plan-interval-actions {
|
.plan-card > .plan-interval-actions,
|
||||||
|
.plan-card__cta {
|
||||||
margin-top: auto;
|
margin-top: auto;
|
||||||
}
|
}
|
||||||
|
.plan-card__cta .plan-interval-actions:first-child,
|
||||||
|
.plan-card__cta > .btn:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
|
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; }
|
||||||
.feature-card {
|
.feature-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2458,6 +2463,162 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
}
|
}
|
||||||
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
|
.stripe-secure--compact i { font-size: 0.95rem; color: #888; }
|
||||||
|
|
||||||
|
.refund-guarantee--pricing {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 18px 20px;
|
||||||
|
align-items: start;
|
||||||
|
margin-top: 24px;
|
||||||
|
padding: 22px 24px;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(229, 57, 53, 0.07), transparent 42%),
|
||||||
|
#14141c;
|
||||||
|
border: 1px solid #2a2a36;
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.14);
|
||||||
|
}
|
||||||
|
.refund-guarantee__icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(229, 57, 53, 0.12);
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
line-height: 1;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.refund-guarantee__copy { min-width: 0; }
|
||||||
|
.refund-guarantee__title {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
color: #f0f0f4;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.refund-guarantee__kicker {
|
||||||
|
display: none;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #c8c8d0;
|
||||||
|
}
|
||||||
|
.refund-guarantee__subtitle {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.refund-guarantee__body {
|
||||||
|
margin: 0;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-width: 46rem;
|
||||||
|
}
|
||||||
|
.refund-guarantee__more {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.refund-guarantee__more a {
|
||||||
|
color: #ff8a80;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.refund-guarantee__more a:hover {
|
||||||
|
color: #ff6e63;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.refund-guarantee--compact {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
padding: 10px 4px 0;
|
||||||
|
border-top: 1px solid #2a2a36;
|
||||||
|
color: #c8c8d0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.refund-guarantee--compact i {
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 0.88rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.refund-guarantee--cta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 14px 0 0;
|
||||||
|
color: #e8e8ee;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
}
|
||||||
|
.refund-guarantee--cta i {
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.refund-guarantee--cta span {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.refund-guarantee--checkout {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 12px 0 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid #2a2a36;
|
||||||
|
border-left: 3px solid #e53935;
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #aaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.refund-guarantee--checkout i {
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-top: 2px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.refund-guarantee--checkout p { margin: 0; }
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.refund-guarantee--pricing {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.refund-guarantee--pricing .refund-guarantee__icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.refund-guarantee__kicker { display: inline; }
|
||||||
|
.refund-guarantee--pricing .refund-guarantee__subtitle { display: none; }
|
||||||
|
.refund-guarantee__title { font-size: 0.98rem; }
|
||||||
|
.refund-guarantee__body { font-size: 0.86rem; }
|
||||||
|
.refund-guarantee--compact {
|
||||||
|
font-size: 0.76rem;
|
||||||
|
padding-top: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
.refund-guarantee--cta {
|
||||||
|
font-size: 0.88rem;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
|
.site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; }
|
||||||
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; }
|
.site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; align-items: center; }
|
||||||
.site-footer__brand {
|
.site-footer__brand {
|
||||||
@@ -2627,6 +2788,7 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
padding: 0 18px;
|
padding: 0 18px;
|
||||||
|
scroll-margin-top: 88px;
|
||||||
}
|
}
|
||||||
.faq-item summary {
|
.faq-item summary {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -2646,6 +2808,7 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
.faq-item p { color: #aaa; margin: 0 0 16px; line-height: 1.55; font-size: 0.95rem; }
|
.faq-item p { color: #aaa; margin: 0 0 16px; line-height: 1.55; font-size: 0.95rem; }
|
||||||
|
|
||||||
.legal-doc { max-width: 760px; padding-top: 24px; padding-bottom: 48px; color: #bbb; line-height: 1.65; font-size: 0.95rem; }
|
.legal-doc { max-width: 760px; padding-top: 24px; padding-bottom: 48px; color: #bbb; line-height: 1.65; font-size: 0.95rem; }
|
||||||
|
.legal-doc section { scroll-margin-top: 88px; }
|
||||||
.legal-doc h1 { color: #fff; font-size: 1.6rem; margin-bottom: 8px; }
|
.legal-doc h1 { color: #fff; font-size: 1.6rem; margin-bottom: 8px; }
|
||||||
.legal-doc h2 { color: #fff; font-size: 1.1rem; margin: 28px 0 10px; }
|
.legal-doc h2 { color: #fff; font-size: 1.1rem; margin: 28px 0 10px; }
|
||||||
.legal-doc h3 { color: #ddd; font-size: 1rem; margin: 20px 0 8px; }
|
.legal-doc h3 { color: #ddd; font-size: 1rem; margin: 20px 0 8px; }
|
||||||
@@ -3549,3 +3712,298 @@ a.replay-archive__thumb:hover {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.feature-card__badge--gold {
|
||||||
|
background: rgba(255, 183, 77, 0.12);
|
||||||
|
color: #ffb74d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sponsor-cover { padding-top: 8px; }
|
||||||
|
.sponsor-cover.section { padding-top: 28px; padding-bottom: 28px; }
|
||||||
|
.sponsor-cover--compact.section { padding-top: 16px; padding-bottom: 8px; }
|
||||||
|
.sponsor-cover__panel {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.15fr) minmax(0, 0.95fr);
|
||||||
|
gap: 32px;
|
||||||
|
align-items: center;
|
||||||
|
background: #14141c;
|
||||||
|
border: 1px solid #5a2a2e;
|
||||||
|
border-radius: 14px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.18), 0 12px 32px rgba(0, 0, 0, 0.28);
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .sponsor-cover__panel {
|
||||||
|
padding: 24px 22px;
|
||||||
|
gap: 24px;
|
||||||
|
box-shadow: 0 0 0 1px rgba(229, 57, 53, 0.12);
|
||||||
|
}
|
||||||
|
.sponsor-cover__copy { min-width: 0; }
|
||||||
|
.sponsor-cover__copy .feature-card__badge { margin-bottom: 12px; }
|
||||||
|
.sponsor-cover__copy h2 {
|
||||||
|
text-align: left;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 1.7rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .sponsor-cover__copy h2 { font-size: 1.4rem; }
|
||||||
|
.sponsor-cover__lead {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: #bbb;
|
||||||
|
line-height: 1.6;
|
||||||
|
max-width: 36rem;
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .sponsor-cover__lead { margin-bottom: 18px; }
|
||||||
|
.sponsor-cover__copy .features-checklist { margin-bottom: 14px; }
|
||||||
|
.sponsor-cover__claim {
|
||||||
|
margin: 0 0 18px;
|
||||||
|
color: #ffb74d;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
.sponsor-cover__visual {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.cover-mock {
|
||||||
|
width: min(100%, 380px);
|
||||||
|
margin: 0;
|
||||||
|
background: #101016;
|
||||||
|
border: 1px solid #2f2f3c;
|
||||||
|
border-radius: 14px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .cover-mock { width: min(100%, 320px); }
|
||||||
|
.cover-mock__chrome {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid #2a2a36;
|
||||||
|
background: #16161e;
|
||||||
|
}
|
||||||
|
.cover-mock__brand {
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.cover-mock__brand em {
|
||||||
|
font-style: normal;
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
.cover-mock__state {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(255, 183, 77, 0.14);
|
||||||
|
color: #ffb74d;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
.cover-mock__stage {
|
||||||
|
padding: 16px 16px 14px;
|
||||||
|
background:
|
||||||
|
radial-gradient(ellipse 80% 50% at 50% 0%, rgba(229, 57, 53, 0.16), transparent 60%),
|
||||||
|
#101016;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.cover-mock__cat {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #9a9aaa;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cover-mock__home,
|
||||||
|
.cover-mock__away {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
font-weight: 800;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .cover-mock__home,
|
||||||
|
.sponsor-cover--compact .cover-mock__away { font-size: 0.92rem; }
|
||||||
|
.cover-mock__vs {
|
||||||
|
margin: 4px 0;
|
||||||
|
color: #e53935;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cover-mock__when {
|
||||||
|
margin: 8px 0 10px;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
.cover-mock__art-label {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #8e8e9a;
|
||||||
|
font-size: 0.62rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cover-mock__art {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 168px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 22px 16px 16px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background:
|
||||||
|
linear-gradient(155deg, rgba(229, 57, 53, 0.22) 0%, transparent 38%),
|
||||||
|
linear-gradient(180deg, #1c1418 0%, #121218 55%, #0e0e14 100%);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||||
|
}
|
||||||
|
.cover-mock__art::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: -24%;
|
||||||
|
right: -18%;
|
||||||
|
width: 58%;
|
||||||
|
height: 90%;
|
||||||
|
background: linear-gradient(135deg, rgba(229, 57, 53, 0.28), transparent 70%);
|
||||||
|
transform: rotate(18deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .cover-mock__art {
|
||||||
|
min-height: 132px;
|
||||||
|
padding: 16px 14px 14px;
|
||||||
|
}
|
||||||
|
.cover-mock__crest {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.cover-mock__crest-mark {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: linear-gradient(180deg, #e53935, #b71c1c);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
box-shadow: 0 8px 18px rgba(229, 57, 53, 0.28);
|
||||||
|
}
|
||||||
|
.sponsor-cover--compact .cover-mock__crest-mark {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 1rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.cover-mock__crest-name {
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.cover-mock__marks {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
.cover-mock__mark {
|
||||||
|
display: block;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 4px;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
.cover-mock__mark--a {
|
||||||
|
width: 46px;
|
||||||
|
background: linear-gradient(90deg, #c9a227, #f0d78c);
|
||||||
|
}
|
||||||
|
.cover-mock__mark--b {
|
||||||
|
width: 34px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #d7d7e0;
|
||||||
|
}
|
||||||
|
.cover-mock__mark--c {
|
||||||
|
width: 40px;
|
||||||
|
background: #8aa4c8;
|
||||||
|
}
|
||||||
|
.cover-mock__soon {
|
||||||
|
margin: 0;
|
||||||
|
color: #888;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 999px) {
|
||||||
|
.sponsor-cover__panel {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
text-align: center;
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
.sponsor-cover__copy .feature-card__badge { align-self: center; }
|
||||||
|
.sponsor-cover__copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.sponsor-cover__copy h2 { text-align: center; }
|
||||||
|
.sponsor-cover__lead { margin-left: auto; margin-right: auto; }
|
||||||
|
.sponsor-cover__copy .features-checklist { text-align: left; width: 100%; max-width: 28rem; }
|
||||||
|
.cover-mock { width: min(100%, 360px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.sponsor-cover__panel { padding: 20px 16px; }
|
||||||
|
.sponsor-cover__copy h2 { font-size: 1.35rem; }
|
||||||
|
.cover-mock__home,
|
||||||
|
.cover-mock__away { font-size: 0.95rem; }
|
||||||
|
.cover-mock__art { min-height: 148px; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.mltv-preview-badge {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 16px;
|
||||||
|
left: 16px;
|
||||||
|
z-index: 1200;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(20, 20, 28, 0.94);
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: #ddd;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mltv-preview-badge__link {
|
||||||
|
color: #ff8a80;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mltv-preview-badge__link:hover {
|
||||||
|
color: #ff5252;
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,11 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isAnalyticsSuppressed() {
|
||||||
|
var meta = document.querySelector("meta[name='mltv-analytics-suppress']");
|
||||||
|
return meta && meta.getAttribute("content") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
function hasAnalyticsConsent() {
|
function hasAnalyticsConsent() {
|
||||||
try {
|
try {
|
||||||
var raw = localStorage.getItem(STORAGE_KEY);
|
var raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -104,7 +109,7 @@
|
|||||||
|
|
||||||
function flush(useBeacon) {
|
function flush(useBeacon) {
|
||||||
flushMovesIntoQueue();
|
flushMovesIntoQueue();
|
||||||
if (!queue.length || !hasAnalyticsConsent()) {
|
if (!queue.length || !hasAnalyticsConsent() || isAnalyticsSuppressed()) {
|
||||||
queue = [];
|
queue = [];
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -236,7 +241,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function captureSnapshot() {
|
function captureSnapshot() {
|
||||||
if (!hasAnalyticsConsent() || alreadyCapturedToday()) return;
|
if (!hasAnalyticsConsent() || isAnalyticsSuppressed() || alreadyCapturedToday()) return;
|
||||||
loadHtml2Canvas(function (html2canvas) {
|
loadHtml2Canvas(function (html2canvas) {
|
||||||
var size = pageSize();
|
var size = pageSize();
|
||||||
html2canvas(document.documentElement, {
|
html2canvas(document.documentElement, {
|
||||||
@@ -278,9 +283,31 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function trackNamedEvent(name) {
|
||||||
|
if (!name) return;
|
||||||
|
if (typeof window.gtag === "function") {
|
||||||
|
window.gtag("event", name, { event_category: "engagement" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNamedEventClick(event) {
|
||||||
|
var target = event.target;
|
||||||
|
if (!(target instanceof Element)) return;
|
||||||
|
var el = target.closest("[data-mltv-event-click]");
|
||||||
|
if (!el) return;
|
||||||
|
trackNamedEvent(el.getAttribute("data-mltv-event-click"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDetailsToggle(event) {
|
||||||
|
var el = event.target;
|
||||||
|
if (!(el instanceof HTMLDetailsElement) || !el.open) return;
|
||||||
|
trackNamedEvent(el.getAttribute("data-mltv-event"));
|
||||||
|
}
|
||||||
|
|
||||||
function start() {
|
function start() {
|
||||||
if (started) return;
|
if (started) return;
|
||||||
if (excludedPath(location.pathname)) return;
|
if (excludedPath(location.pathname)) return;
|
||||||
|
if (isAnalyticsSuppressed()) return;
|
||||||
if (!hasAnalyticsConsent()) return;
|
if (!hasAnalyticsConsent()) return;
|
||||||
started = true;
|
started = true;
|
||||||
maxScroll = scrollPct();
|
maxScroll = scrollPct();
|
||||||
@@ -304,6 +331,9 @@
|
|||||||
|
|
||||||
window.mltvSiteAnalyticsStart = start;
|
window.mltvSiteAnalyticsStart = start;
|
||||||
|
|
||||||
|
document.addEventListener("click", onNamedEventClick, true);
|
||||||
|
document.addEventListener("toggle", onDetailsToggle, true);
|
||||||
|
|
||||||
if (document.readyState === "loading") {
|
if (document.readyState === "loading") {
|
||||||
document.addEventListener("DOMContentLoaded", start);
|
document.addEventListener("DOMContentLoaded", start);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe MatchLiveTv::Demo do
|
||||||
|
it "usa Team MLTV come squadra demo ufficiale" do
|
||||||
|
expect(described_class.home_team).to eq("Team MLTV")
|
||||||
|
expect(described_class.category).to eq("U15 MASCHILE")
|
||||||
|
expect(described_class.venue).to eq("Pala MLTV")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "localizza l'avversario senza nomi di società reali" do
|
||||||
|
I18n.with_locale(:it) do
|
||||||
|
expect(described_class.away_team).to eq("Squadra ospite")
|
||||||
|
expect(described_class.match_title).to eq("Team MLTV vs Squadra ospite")
|
||||||
|
end
|
||||||
|
I18n.with_locale(:en) do
|
||||||
|
expect(described_class.away_team).to eq("Team Guest")
|
||||||
|
expect(described_class.match_title).to eq("Team MLTV vs Team Guest")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -109,6 +109,28 @@ RSpec.describe "Account API", type: :request do
|
|||||||
expect(user.reload.password_reset_digest).to be_present
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "non va in 500 se in produzione manca SMTP" do
|
||||||
|
allow(MatchLiveTv).to receive(:smtp_configured?).and_return(false)
|
||||||
|
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
|
it "non va in 500 se SMTP rifiuta il destinatario" do
|
||||||
|
allow_any_instance_of(ActionMailer::MessageDelivery).to receive(:deliver_now)
|
||||||
|
.and_raise(Net::SMTPFatalError.new("556 5.1.10 invalid destination domain"))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||||
|
}.not_to raise_error
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(user.reload.password_reset_digest).to be_present
|
||||||
|
end
|
||||||
|
|
||||||
it "returns the same message for unknown emails" do
|
it "returns the same message for unknown emails" do
|
||||||
expect {
|
expect {
|
||||||
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||||
|
|||||||
@@ -35,10 +35,40 @@ RSpec.describe "Admin analytics", type: :request do
|
|||||||
expect(response.body).to include("40")
|
expect(response.body).to include("40")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "mostra la heatmap di una pagina" do
|
it "mostra la heatmap di una pagina con tab dispositivo" do
|
||||||
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
||||||
expect(response).to have_http_status(:ok)
|
expect(response).to have_http_status(:ok)
|
||||||
expect(response.body).to include("admin-heatmap")
|
expect(response.body).to include("admin-heatmap")
|
||||||
expect(response.body).to include("/prezzi")
|
expect(response.body).to include("/prezzi")
|
||||||
|
expect(response.body).to include("is-active")
|
||||||
|
expect(response.body).to include(I18n.t("admin.analytics.devices.desktop"))
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra solo i punti del dispositivo selezionato" do
|
||||||
|
AnalyticsPageCell.create!(
|
||||||
|
day: Time.zone.today,
|
||||||
|
page_path: "/prezzi",
|
||||||
|
device: "mobile",
|
||||||
|
cell_x: 5,
|
||||||
|
cell_y: 8,
|
||||||
|
click_count: 9,
|
||||||
|
move_count: 0
|
||||||
|
)
|
||||||
|
|
||||||
|
get admin_analytics_page_path, params: { page_path: "/prezzi", device: "mobile", layer: "click" }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include("x":5")
|
||||||
|
expect(response.body).to include("c":9")
|
||||||
|
expect(response.body).not_to include("x":10")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "attiva e disattiva l'anteprima staff" do
|
||||||
|
post admin_analytics_preview_path, params: { return_to: "/" }
|
||||||
|
expect(response).to redirect_to("/")
|
||||||
|
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to eq("1")
|
||||||
|
|
||||||
|
delete admin_analytics_preview_path
|
||||||
|
expect(response).to redirect_to(admin_analytics_path)
|
||||||
|
expect(response.cookies[Analytics::Suppress::COOKIE_NAME]).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user