Compare commits
7
Commits
main
..
1185d0ce61
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1185d0ce61 | ||
|
|
90d481a2d1 | ||
|
|
50b8a8c8e2 | ||
|
|
79850dfc2c | ||
|
|
645807b853 | ||
|
|
b08049cf69 | ||
|
|
29c8afb94d |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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?
|
||||||
|
|
||||||
|
|||||||
@@ -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,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}")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
<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=15">
|
||||||
|
<%= 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 %>
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
<%= 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") %>
|
||||||
<%= 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=77">
|
||||||
</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=5" 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=77">
|
||||||
<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=5" defer></script>
|
||||||
<script src="/cookie-consent.js?v=2" defer></script>
|
<script src="/cookie-consent.js?v=3" defer></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ de:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sitzungen
|
sessions: Sitzungen
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Kosten
|
||||||
stream_nodes: Stream-Knoten
|
stream_nodes: Stream-Knoten
|
||||||
password: Passwort
|
password: Passwort
|
||||||
logout: Abmelden
|
logout: Abmelden
|
||||||
@@ -50,6 +51,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"
|
||||||
@@ -371,6 +375,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 +399,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:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ en:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
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 +51,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"
|
||||||
@@ -371,6 +375,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 +399,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:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ es:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sesiones
|
sessions: Sesiones
|
||||||
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 +51,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í"
|
||||||
@@ -371,6 +375,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 +399,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:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ fr:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessions
|
sessions: Sessions
|
||||||
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 +51,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"
|
||||||
@@ -371,6 +375,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 +399,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:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ it:
|
|||||||
youtube: YouTube
|
youtube: YouTube
|
||||||
sessions: Sessioni
|
sessions: Sessioni
|
||||||
analytics: Analytics
|
analytics: Analytics
|
||||||
|
costs: Costi
|
||||||
stream_nodes: Nodi stream
|
stream_nodes: Nodi stream
|
||||||
password: Password
|
password: Password
|
||||||
logout: Esci
|
logout: Esci
|
||||||
@@ -54,6 +55,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ì"
|
||||||
@@ -392,6 +396,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 +420,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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -130,8 +130,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
|
||||||
Generated
+12
-1
@@ -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_28_180000) 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
|
||||||
|
|||||||
@@ -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: "Tigers Volley", sport: "volleyball", plan: "premium_full", sessions: 14, hours: 18.5, revenue_yearly: 19900 },
|
||||||
|
{ name: "ASD Eagles Milano", sport: "volleyball", plan: "premium_light", sessions: 9, hours: 11.0, revenue_yearly: 5900 },
|
||||||
|
{ name: "Volley Stars Roma", sport: "volleyball", plan: "premium_full", sessions: 6, hours: 8.0, revenue_yearly: 19900 },
|
||||||
|
{ name: "Basket Juventus U18", sport: "basketball", plan: "premium_light", sessions: 4, hours: 5.5, revenue_yearly: 5900 },
|
||||||
|
{ name: "Padova Beach", 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: "Avversario demo #{n + 1}",
|
||||||
|
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,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 } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -693,6 +693,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 +800,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);
|
||||||
|
|||||||
@@ -3549,3 +3549,31 @@ a.replay-archive__thumb:hover {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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, {
|
||||||
@@ -281,6 +286,7 @@
|
|||||||
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();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe "Admin costs", type: :request do
|
||||||
|
let!(:admin) { AdminAccount.create!(username: "ops-costs", password: "Password123") }
|
||||||
|
let(:month) { Time.zone.today.beginning_of_month }
|
||||||
|
|
||||||
|
before do
|
||||||
|
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||||
|
PlatformCostEntry.create!(month: month, label: "Piattaforma", amount_cents: 8_500)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "mostra la dashboard costi" do
|
||||||
|
get admin_costs_path, params: { month: month.strftime("%Y-%m") }
|
||||||
|
expect(response).to have_http_status(:ok)
|
||||||
|
expect(response.body).to include(I18n.t("admin.costs.index.title"))
|
||||||
|
expect(response.body).to include("85.00")
|
||||||
|
end
|
||||||
|
|
||||||
|
it "crea, aggiorna ed elimina una voce di costo" do
|
||||||
|
post admin_cost_entries_path,
|
||||||
|
params: {
|
||||||
|
platform_cost_entry: {
|
||||||
|
month: month.strftime("%Y-%m"),
|
||||||
|
label: "Stream CPX",
|
||||||
|
amount_euros: "42.50",
|
||||||
|
notes: "overflow nodes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
|
||||||
|
entry = PlatformCostEntry.find_by(label: "Stream CPX")
|
||||||
|
expect(entry.amount_cents).to eq(4_250)
|
||||||
|
|
||||||
|
patch admin_cost_entry_path(entry),
|
||||||
|
params: { platform_cost_entry: { month: month.strftime("%Y-%m"), label: "Stream CPX", amount_euros: "50" } }
|
||||||
|
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
|
||||||
|
expect(entry.reload.amount_cents).to eq(5_000)
|
||||||
|
|
||||||
|
delete admin_cost_entry_path(entry)
|
||||||
|
expect(response).to redirect_to(admin_costs_path(month: month.strftime("%Y-%m")))
|
||||||
|
expect(PlatformCostEntry.find_by(id: entry.id)).to be_nil
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -52,6 +52,24 @@ RSpec.describe "Analytics ingest", type: :request do
|
|||||||
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
|
expect(AnalyticsPageCell.where(page_path: "/clubs/:id").sum(:move_count)).to eq(12)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "ignora eventi con cookie anteprima staff" do
|
||||||
|
expect do
|
||||||
|
post "/analytics/events",
|
||||||
|
params: {
|
||||||
|
events: [
|
||||||
|
{ type: "pageview", path: "/prezzi-suppress", device: "desktop", ts: Time.current.to_i * 1000 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" },
|
||||||
|
as: :json
|
||||||
|
end.not_to change { AnalyticsPageStat.where(page_path: "/prezzi-suppress").count }
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["accepted"]).to eq(0)
|
||||||
|
expect(body["suppressed"]).to eq(true)
|
||||||
|
end
|
||||||
|
|
||||||
it "rifiuta path esclusi" do
|
it "rifiuta path esclusi" do
|
||||||
post "/analytics/events",
|
post "/analytics/events",
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -29,16 +29,36 @@ RSpec.describe "Analytics snapshots", type: :request do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it "rifiuta path esclusi" do
|
it "rifiuta path esclusi" do
|
||||||
post "/analytics/snapshot",
|
expect do
|
||||||
params: {
|
post "/analytics/snapshot",
|
||||||
path: "/admin/analytics",
|
params: {
|
||||||
device: "desktop",
|
path: "/admin/analytics",
|
||||||
width: 1440,
|
device: "desktop",
|
||||||
height: 900,
|
width: 1440,
|
||||||
image: jpeg_upload
|
height: 900,
|
||||||
}
|
image: jpeg_upload
|
||||||
|
}
|
||||||
|
end.not_to change(AnalyticsPageSnapshot, :count)
|
||||||
|
|
||||||
expect(response).to have_http_status(:unprocessable_content)
|
expect(response).to have_http_status(:unprocessable_content)
|
||||||
expect(AnalyticsPageSnapshot.count).to eq(0)
|
end
|
||||||
|
|
||||||
|
it "salta snapshot con cookie anteprima staff" do
|
||||||
|
expect do
|
||||||
|
post "/analytics/snapshot",
|
||||||
|
params: {
|
||||||
|
path: "/prezzi-suppress",
|
||||||
|
device: "desktop",
|
||||||
|
width: 1440,
|
||||||
|
height: 2200,
|
||||||
|
image: jpeg_upload
|
||||||
|
},
|
||||||
|
headers: { "Cookie" => "#{Analytics::Suppress::COOKIE_NAME}=1" }
|
||||||
|
end.not_to change(AnalyticsPageSnapshot, :count)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:accepted)
|
||||||
|
body = JSON.parse(response.body)
|
||||||
|
expect(body["skipped"]).to eq(true)
|
||||||
|
expect(body["suppressed"]).to eq(true)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require "rails_helper"
|
||||||
|
|
||||||
|
RSpec.describe Admin::CostAnalytics do
|
||||||
|
let!(:coach) do
|
||||||
|
User.create!(email: "cost-coach@test.it", name: "Coach", password: "Password123", role: "coach")
|
||||||
|
end
|
||||||
|
let!(:club) { Club.create!(name: "Tigers Club", sport: "volleyball") }
|
||||||
|
let!(:team) { club.teams.create!(name: "Tigers U16", sport_key: "pallavolo") }
|
||||||
|
let!(:match) { team.matches.create!(opponent_name: "Rival", sport_key: "pallavolo") }
|
||||||
|
let(:month) { Date.new(2024, 3, 1) }
|
||||||
|
|
||||||
|
before do
|
||||||
|
PlatformCostEntry.delete_all
|
||||||
|
PlatformCostEntry.create!(
|
||||||
|
month: month,
|
||||||
|
label: "Hetzner",
|
||||||
|
amount_cents: 10_000
|
||||||
|
)
|
||||||
|
StreamSession.create!(
|
||||||
|
match: match,
|
||||||
|
user: coach,
|
||||||
|
platform: "matchlivetv",
|
||||||
|
status: "ended",
|
||||||
|
started_at: month + 1.day + 10.hours,
|
||||||
|
ended_at: month + 1.day + 11.hours,
|
||||||
|
total_duration_secs: 3600
|
||||||
|
)
|
||||||
|
Billing::Payment.create!(
|
||||||
|
club: club,
|
||||||
|
amount_cents: 5_900,
|
||||||
|
currency: "EUR",
|
||||||
|
status: "paid",
|
||||||
|
provider: "stripe",
|
||||||
|
paid_at: month + 2.days
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "calcola KPI e allocazione per società" do
|
||||||
|
analytics = Admin::CostAnalytics.new(month: month)
|
||||||
|
summary = analytics.summary
|
||||||
|
|
||||||
|
expect(summary[:cost_cents]).to eq(10_000)
|
||||||
|
expect(summary[:sessions]).to eq(1)
|
||||||
|
expect(summary[:hours]).to eq(1.0)
|
||||||
|
expect(summary[:revenue_cents]).to eq(5_900)
|
||||||
|
expect(summary[:margin_cents]).to eq(-4_100)
|
||||||
|
expect(summary[:cost_per_hour_cents]).to eq(10_000)
|
||||||
|
|
||||||
|
clubs = analytics.club_breakdown
|
||||||
|
expect(clubs.size).to eq(1)
|
||||||
|
expect(clubs.first[:club_name]).to eq("Tigers Club")
|
||||||
|
expect(clubs.first[:allocated_cost_cents]).to eq(10_000)
|
||||||
|
expect(clubs.first[:revenue_cents]).to eq(5_900)
|
||||||
|
end
|
||||||
|
|
||||||
|
it "ricalcola dopo modifica delle voci di costo" do
|
||||||
|
PlatformCostEntry.create!(month: month, label: "S3", amount_cents: 2_000)
|
||||||
|
|
||||||
|
summary = Admin::CostAnalytics.new(month: month).summary
|
||||||
|
expect(summary[:cost_cents]).to eq(12_000)
|
||||||
|
expect(summary[:cost_per_session_cents]).to eq(12_000)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -40,6 +40,44 @@ RSpec.describe Ops::HealthChecks do
|
|||||||
|
|
||||||
expect(Ops::Incident.find_by(fingerprint: "disk_space:root").status).to eq("resolved")
|
expect(Ops::Incident.find_by(fingerprint: "disk_space:root").status).to eq("resolved")
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "chiude gli incidenti overflow anche se la fingerprint sana è diversa" do
|
||||||
|
Ops::Incident.create!(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
status: "open",
|
||||||
|
title: "Capacità stream al massimo",
|
||||||
|
message: "overflow=3/3",
|
||||||
|
metadata: {},
|
||||||
|
fingerprint: "stream_overflow:at_max",
|
||||||
|
occurrence_count: 1,
|
||||||
|
first_seen_at: Time.current,
|
||||||
|
last_seen_at: Time.current
|
||||||
|
)
|
||||||
|
|
||||||
|
allow_any_instance_of(described_class).to receive(:check_stream_overflow).and_return(
|
||||||
|
described_class::Finding.new(
|
||||||
|
kind: "stream_overflow", severity: "info", healthy: true,
|
||||||
|
title: "Overflow streaming OK", message: "OK", metadata: {}, fingerprint: "stream_overflow:ok"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
%i[
|
||||||
|
check_disk_root check_recordings_size check_postgres check_redis check_mediamtx check_garage
|
||||||
|
check_sidekiq_heartbeat check_sidekiq_dead check_http_rails check_rails_latency
|
||||||
|
].each do |method|
|
||||||
|
allow_any_instance_of(described_class).to receive(method).and_return(
|
||||||
|
described_class::Finding.new(
|
||||||
|
kind: "test", severity: "info", healthy: true,
|
||||||
|
title: "OK", message: "OK", metadata: {}, fingerprint: "#{method}:ok"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
allow_any_instance_of(described_class).to receive(:public_check_due?).and_return(false)
|
||||||
|
|
||||||
|
described_class.new.call
|
||||||
|
|
||||||
|
expect(Ops::Incident.find_by(fingerprint: "stream_overflow:at_max").status).to eq("resolved")
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "#summary" do
|
describe "#summary" do
|
||||||
|
|||||||
@@ -52,4 +52,28 @@ RSpec.describe Ops::IncidentRecorder do
|
|||||||
expect(incident.reload.status).to eq("resolved")
|
expect(incident.reload.status).to eq("resolved")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe ".resolve_kind" do
|
||||||
|
it "chiude tutti gli incidenti aperti di quel kind" do
|
||||||
|
at_max = described_class.record(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
title: "Max",
|
||||||
|
message: "max",
|
||||||
|
fingerprint: "stream_overflow:at_max"
|
||||||
|
)
|
||||||
|
orphan = described_class.record(
|
||||||
|
kind: "stream_overflow",
|
||||||
|
severity: "warning",
|
||||||
|
title: "Idle",
|
||||||
|
message: "idle",
|
||||||
|
fingerprint: "stream_overflow:orphan_idle"
|
||||||
|
)
|
||||||
|
|
||||||
|
described_class.resolve_kind("stream_overflow")
|
||||||
|
|
||||||
|
expect(at_max.reload.status).to eq("resolved")
|
||||||
|
expect(orphan.reload.status).to eq("resolved")
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,4 +27,22 @@ RSpec.describe Recordings::NotifyReady do
|
|||||||
|
|
||||||
expect(recording.reload.ready_notified_at).to be_present
|
expect(recording.reload.ready_notified_at).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
|
it "marca notificato anche se in produzione manca SMTP" do
|
||||||
|
recording = Recording.create!(
|
||||||
|
stream_session: session,
|
||||||
|
team: team,
|
||||||
|
status: "ready",
|
||||||
|
expires_at: 10.days.from_now,
|
||||||
|
storage_key: "key"
|
||||||
|
)
|
||||||
|
allow(MatchLiveTv).to receive(:smtp_configured?).and_return(false)
|
||||||
|
allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production"))
|
||||||
|
|
||||||
|
expect {
|
||||||
|
described_class.new(recording).call
|
||||||
|
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||||
|
|
||||||
|
expect(recording.reload.ready_notified_at).to be_present
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
|
8F7E6D5C4B3A29180796A5B4 /* HubFlowsUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */; };
|
||||||
06FE9996B295456D8B8C7E96 /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */; };
|
06FE9996B295456D8B8C7E96 /* BroadcastPermissions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BFB2F18615747B1A1C9E9B2 /* BroadcastPermissions.swift */; };
|
||||||
095654DAAF6C4EA4B73BAB61 /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F8BF2514BB4F5185827B95 /* LiveScoreDialogHostTests.swift */; };
|
095654DAAF6C4EA4B73BAB61 /* LiveScoreDialogHostTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4F8BF2514BB4F5185827B95 /* LiveScoreDialogHostTests.swift */; };
|
||||||
0CB8933AC2B34D4099BBA881 /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */; };
|
0CB8933AC2B34D4099BBA881 /* MatchStatusBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = 34BB2251A67E401A9F1E789E /* MatchStatusBadge.swift */; };
|
||||||
@@ -107,9 +108,18 @@
|
|||||||
remoteGlobalIDString = F54F6C97361C4E8A96AEDD11;
|
remoteGlobalIDString = F54F6C97361C4E8A96AEDD11;
|
||||||
remoteInfo = MatchLiveTv;
|
remoteInfo = MatchLiveTv;
|
||||||
};
|
};
|
||||||
|
4B3A29180796A5B4C3D2E1F0 /* PBXContainerItemProxy */ = {
|
||||||
|
isa = PBXContainerItemProxy;
|
||||||
|
containerPortal = AE0076753A1840A19E9F565F /* Project object */;
|
||||||
|
proxyType = 1;
|
||||||
|
remoteGlobalIDString = F54F6C97361C4E8A96AEDD11;
|
||||||
|
remoteInfo = MatchLiveTv;
|
||||||
|
};
|
||||||
/* End PBXContainerItemProxy section */
|
/* End PBXContainerItemProxy section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MatchLiveTvUITests/HubFlowsUITests.swift; sourceTree = "<group>"; };
|
||||||
|
7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = MatchLiveTvUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
004F86D8E8BE47E589B923FA /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; };
|
004F86D8E8BE47E589B923FA /* KeepScreenOn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = KeepScreenOn.swift; path = MatchLiveTv/UI/System/KeepScreenOn.swift; sourceTree = "<group>"; };
|
||||||
1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
|
1079231B9A4E446AA9AD6C0A /* StepNetworkTestScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = StepNetworkTestScreen.swift; path = MatchLiveTv/UI/Wizard/StepNetworkTestScreen.swift; sourceTree = "<group>"; };
|
||||||
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = "<group>"; };
|
10AD0F18190542818572CAEF /* ScoreActionDecodeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = ScoreActionDecodeTests.swift; path = MatchLiveTvTests/ScoreActionDecodeTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -228,6 +238,7 @@
|
|||||||
children = (
|
children = (
|
||||||
CE0F9D7D5EE749E8AAE6AB43 /* MatchLiveTv.app */,
|
CE0F9D7D5EE749E8AAE6AB43 /* MatchLiveTv.app */,
|
||||||
3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */,
|
3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */,
|
||||||
|
7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */,
|
||||||
);
|
);
|
||||||
name = Products;
|
name = Products;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -248,11 +259,20 @@
|
|||||||
name = MatchLiveTvTests;
|
name = MatchLiveTvTests;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
2A180796A5B4C3D2E1F0A98B /* MatchLiveTvUITests */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9C8B7A6D5E4F3A2B1C0D9E8F /* HubFlowsUITests.swift */,
|
||||||
|
);
|
||||||
|
name = MatchLiveTvUITests;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
7E992B84100649D8BE8507F7 = {
|
7E992B84100649D8BE8507F7 = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
DDEF729FE3334F8998C4A31C /* MatchLiveTv */,
|
DDEF729FE3334F8998C4A31C /* MatchLiveTv */,
|
||||||
6987D3982FAD44A6869B9FD6 /* MatchLiveTvTests */,
|
6987D3982FAD44A6869B9FD6 /* MatchLiveTvTests */,
|
||||||
|
2A180796A5B4C3D2E1F0A98B /* MatchLiveTvUITests */,
|
||||||
0ADC6126267C410F9B60B426 /* Products */,
|
0ADC6126267C410F9B60B426 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -364,6 +384,22 @@
|
|||||||
productReference = 3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */;
|
productReference = 3FC0CC8155BE469ABB420DC5 /* MatchLiveTvTests.xctest */;
|
||||||
productType = "com.apple.product-type.bundle.unit-test";
|
productType = "com.apple.product-type.bundle.unit-test";
|
||||||
};
|
};
|
||||||
|
6D5C4B3A29180796A5B4C3D2 /* MatchLiveTvUITests */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = F01796A5B4C3D2E1F0A98D7E /* Build configuration list for PBXNativeTarget "MatchLiveTvUITests" */;
|
||||||
|
buildPhases = (
|
||||||
|
5C4B3A29180796A5B4C3D2E1 /* Sources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
3A29180796A5B4C3D2E1F0A9 /* PBXTargetDependency */,
|
||||||
|
);
|
||||||
|
name = MatchLiveTvUITests;
|
||||||
|
productName = MatchLiveTvUITests;
|
||||||
|
productReference = 7E6D5C4B3A29180796A5B4C3 /* MatchLiveTvUITests.xctest */;
|
||||||
|
productType = "com.apple.product-type.bundle.ui-testing";
|
||||||
|
};
|
||||||
F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */ = {
|
F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */ = {
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 117DCD84E04B4B03A2BFFFEA /* Build configuration list for PBXNativeTarget "MatchLiveTv" */;
|
buildConfigurationList = 117DCD84E04B4B03A2BFFFEA /* Build configuration list for PBXNativeTarget "MatchLiveTv" */;
|
||||||
@@ -414,6 +450,7 @@
|
|||||||
targets = (
|
targets = (
|
||||||
F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */,
|
F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */,
|
||||||
20C695DBFB4042879308B0F5 /* MatchLiveTvTests */,
|
20C695DBFB4042879308B0F5 /* MatchLiveTvTests */,
|
||||||
|
6D5C4B3A29180796A5B4C3D2 /* MatchLiveTvUITests */,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
/* End PBXProject section */
|
/* End PBXProject section */
|
||||||
@@ -531,6 +568,14 @@
|
|||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
5C4B3A29180796A5B4C3D2E1 /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
8F7E6D5C4B3A29180796A5B4 /* HubFlowsUITests.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
/* End PBXSourcesBuildPhase section */
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXTargetDependency section */
|
/* Begin PBXTargetDependency section */
|
||||||
@@ -539,6 +584,11 @@
|
|||||||
target = F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */;
|
target = F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */;
|
||||||
targetProxy = 58E3C5EC752E497FBFD32D78 /* PBXContainerItemProxy */;
|
targetProxy = 58E3C5EC752E497FBFD32D78 /* PBXContainerItemProxy */;
|
||||||
};
|
};
|
||||||
|
3A29180796A5B4C3D2E1F0A9 /* PBXTargetDependency */ = {
|
||||||
|
isa = PBXTargetDependency;
|
||||||
|
target = F54F6C97361C4E8A96AEDD11 /* MatchLiveTv */;
|
||||||
|
targetProxy = 4B3A29180796A5B4C3D2E1F0 /* PBXContainerItemProxy */;
|
||||||
|
};
|
||||||
/* End PBXTargetDependency section */
|
/* End PBXTargetDependency section */
|
||||||
|
|
||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
@@ -627,10 +677,11 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 32;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -647,7 +698,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 32;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
DEVELOPMENT_TEAM = S8Q9TWBRG5;
|
DEVELOPMENT_TEAM = S8Q9TWBRG5;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
@@ -658,7 +709,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -676,7 +727,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 32;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
DEVELOPMENT_TEAM = S8Q9TWBRG5;
|
DEVELOPMENT_TEAM = S8Q9TWBRG5;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
@@ -688,7 +739,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -705,10 +756,11 @@
|
|||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 32;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests";
|
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.tests";
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -718,6 +770,44 @@
|
|||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
|
19180796A5B4C3D2E1F0A98C /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
|
MARKETING_VERSION = 2.0.12;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_TARGET_NAME = MatchLiveTv;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
081796A5B4C3D2E1F0A98D7E /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
|
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 16.6;
|
||||||
|
MARKETING_VERSION = 2.0.12;
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests";
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = iphoneos;
|
||||||
|
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||||
|
SWIFT_VERSION = 5.0;
|
||||||
|
TEST_TARGET_NAME = MatchLiveTv;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
/* End XCBuildConfiguration section */
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
/* Begin XCConfigurationList section */
|
/* Begin XCConfigurationList section */
|
||||||
@@ -748,6 +838,15 @@
|
|||||||
defaultConfigurationIsVisible = 0;
|
defaultConfigurationIsVisible = 0;
|
||||||
defaultConfigurationName = Release;
|
defaultConfigurationName = Release;
|
||||||
};
|
};
|
||||||
|
F01796A5B4C3D2E1F0A98D7E /* Build configuration list for PBXNativeTarget "MatchLiveTvUITests" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
19180796A5B4C3D2E1F0A98C /* Debug */,
|
||||||
|
081796A5B4C3D2E1F0A98D7E /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Release;
|
||||||
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
|
|||||||
@@ -52,6 +52,16 @@
|
|||||||
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
</BuildableReference>
|
</BuildableReference>
|
||||||
</TestableReference>
|
</TestableReference>
|
||||||
|
<TestableReference
|
||||||
|
skipped = "NO">
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "6D5C4B3A29180796A5B4C3D2"
|
||||||
|
BuildableName = "MatchLiveTvUITests.xctest"
|
||||||
|
BlueprintName = "MatchLiveTvUITests"
|
||||||
|
ReferencedContainer = "container:MatchLiveTv.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</TestableReference>
|
||||||
</Testables>
|
</Testables>
|
||||||
</TestAction>
|
</TestAction>
|
||||||
<LaunchAction
|
<LaunchAction
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ import Foundation
|
|||||||
|
|
||||||
enum AppConfig {
|
enum AppConfig {
|
||||||
static var apiBaseUrl: String {
|
static var apiBaseUrl: String {
|
||||||
|
if let env = ProcessInfo.processInfo.environment["API_BASE_URL"]?.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")),
|
||||||
|
!env.isEmpty {
|
||||||
|
return env
|
||||||
|
}
|
||||||
let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
|
let raw = Bundle.main.object(forInfoDictionaryKey: "API_BASE_URL") as? String
|
||||||
?? "https://www.matchlivetv.it"
|
?? "https://www.matchlivetv.it"
|
||||||
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
return raw.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
||||||
|
|||||||
@@ -1763,6 +1763,7 @@ struct LanguagePickerView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("language.\(lang.rawValue)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(L10n.t("language.label"))
|
.navigationTitle(L10n.t("language.label"))
|
||||||
|
|||||||
@@ -16,8 +16,21 @@ final class TokenStore: ObservableObject {
|
|||||||
private let service = "com.matchlivetv.match-live-tv.auth"
|
private let service = "com.matchlivetv.match-live-tv.auth"
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
session = loadSession()
|
if ProcessInfo.processInfo.arguments.contains("--uitesting-reset") {
|
||||||
activeTeamId = defaults.string(forKey: Keys.activeTeamId)
|
deleteKeychain(key: Keys.accessToken)
|
||||||
|
deleteKeychain(key: Keys.refreshToken)
|
||||||
|
defaults.removeObject(forKey: Keys.userId)
|
||||||
|
defaults.removeObject(forKey: Keys.userEmail)
|
||||||
|
defaults.removeObject(forKey: Keys.userName)
|
||||||
|
defaults.removeObject(forKey: Keys.userRole)
|
||||||
|
defaults.removeObject(forKey: Keys.activeTeamId)
|
||||||
|
defaults.set("it", forKey: "mltv.appLanguage")
|
||||||
|
session = nil
|
||||||
|
activeTeamId = nil
|
||||||
|
} else {
|
||||||
|
session = loadSession()
|
||||||
|
activeTeamId = defaults.string(forKey: Keys.activeTeamId)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func saveSession(_ session: StoredSession) {
|
func saveSession(_ session: StoredSession) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>2.0.10</string>
|
<string>2.0.12</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>32</string>
|
<string>35</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
@@ -41,10 +41,6 @@
|
|||||||
<string>Match Live TV usa il microfono per l'audio della diretta.</string>
|
<string>Match Live TV usa il microfono per l'audio della diretta.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Match Live TV usa le foto per i loghi delle squadre.</string>
|
<string>Match Live TV usa le foto per i loghi delle squadre.</string>
|
||||||
<key>UIBackgroundModes</key>
|
|
||||||
<array>
|
|
||||||
<string>audio</string>
|
|
||||||
</array>
|
|
||||||
<key>UILaunchScreen</key>
|
<key>UILaunchScreen</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIColorName</key>
|
<key>UIColorName</key>
|
||||||
|
|||||||
@@ -30,9 +30,11 @@ struct AccountScreen: View {
|
|||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("account.back")
|
||||||
Text(L10n.t("account.title"))
|
Text(L10n.t("account.title"))
|
||||||
.font(MatchTypography.headlineMedium)
|
.font(MatchTypography.headlineMedium)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
|
.accessibilityIdentifier("account.title")
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
@@ -66,6 +68,7 @@ struct AccountScreen: View {
|
|||||||
enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile,
|
enabled: !loadingProfile && !name.trimmingCharacters(in: .whitespaces).isEmpty && !savingProfile,
|
||||||
loading: savingProfile
|
loading: savingProfile
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("account.save.profile")
|
||||||
|
|
||||||
Text(L10n.t("account.password.heading"))
|
Text(L10n.t("account.password.heading"))
|
||||||
.font(MatchTypography.titleMedium)
|
.font(MatchTypography.titleMedium)
|
||||||
@@ -75,17 +78,20 @@ struct AccountScreen: View {
|
|||||||
MatchAccountSecureField(
|
MatchAccountSecureField(
|
||||||
title: L10n.t("account.current.password"),
|
title: L10n.t("account.current.password"),
|
||||||
text: $currentPassword,
|
text: $currentPassword,
|
||||||
visible: $passwordVisible
|
visible: $passwordVisible,
|
||||||
|
accessibilityId: "account.password.current"
|
||||||
)
|
)
|
||||||
MatchAccountSecureField(
|
MatchAccountSecureField(
|
||||||
title: L10n.t("account.new.password"),
|
title: L10n.t("account.new.password"),
|
||||||
text: $newPassword,
|
text: $newPassword,
|
||||||
visible: $passwordVisible
|
visible: $passwordVisible,
|
||||||
|
accessibilityId: "account.password.new"
|
||||||
)
|
)
|
||||||
MatchAccountSecureField(
|
MatchAccountSecureField(
|
||||||
title: L10n.t("account.confirm.password"),
|
title: L10n.t("account.confirm.password"),
|
||||||
text: $confirmPassword,
|
text: $confirmPassword,
|
||||||
visible: $passwordVisible
|
visible: $passwordVisible,
|
||||||
|
accessibilityId: "account.password.confirm"
|
||||||
)
|
)
|
||||||
|
|
||||||
if let passwordError {
|
if let passwordError {
|
||||||
@@ -101,12 +107,14 @@ struct AccountScreen: View {
|
|||||||
enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword,
|
enabled: !currentPassword.isEmpty && !newPassword.isEmpty && !confirmPassword.isEmpty && !savingPassword,
|
||||||
loading: savingPassword
|
loading: savingPassword
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("account.save.password")
|
||||||
|
|
||||||
MatchSecondaryButton(
|
MatchSecondaryButton(
|
||||||
label: L10n.t("action.logout"),
|
label: L10n.t("action.logout"),
|
||||||
action: logout,
|
action: logout,
|
||||||
enabled: !loggingOut
|
enabled: !loggingOut
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("account.logout")
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
@@ -214,6 +222,8 @@ private struct MatchAccountSecureField: View {
|
|||||||
@Binding var text: String
|
@Binding var text: String
|
||||||
@Binding var visible: Bool
|
@Binding var visible: Bool
|
||||||
|
|
||||||
|
var accessibilityId: String = ""
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(title)
|
Text(title)
|
||||||
@@ -229,6 +239,7 @@ private struct MatchAccountSecureField: View {
|
|||||||
}
|
}
|
||||||
.textInputAutocapitalization(.never)
|
.textInputAutocapitalization(.never)
|
||||||
.autocorrectionDisabled()
|
.autocorrectionDisabled()
|
||||||
|
.accessibilityIdentifier(accessibilityId)
|
||||||
Button(action: { visible.toggle() }) {
|
Button(action: { visible.toggle() }) {
|
||||||
Image(systemName: visible ? "eye.slash" : "eye")
|
Image(systemName: visible ? "eye.slash" : "eye")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
|||||||
@@ -194,16 +194,19 @@ struct BroadcastControlsOverlay: View {
|
|||||||
action: { pendingConfirm = .shareLive },
|
action: { pendingConfirm = .shareLive },
|
||||||
enabled: shareLiveEnabled
|
enabled: shareLiveEnabled
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("broadcast.share")
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "video.fill",
|
systemName: "video.fill",
|
||||||
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
accessibilityLabel: L10n.t("broadcast.share.regia.cd"),
|
||||||
action: { pendingConfirm = .shareRegia }
|
action: { pendingConfirm = .shareRegia }
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("broadcast.regia")
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "slider.horizontal.3",
|
systemName: "slider.horizontal.3",
|
||||||
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
accessibilityLabel: L10n.t("broadcast.min.quality.cd"),
|
||||||
action: { showMinQualityPicker = true }
|
action: { showMinQualityPicker = true }
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("broadcast.abr")
|
||||||
if let onCloseSet {
|
if let onCloseSet {
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "checkmark",
|
systemName: "checkmark",
|
||||||
@@ -231,18 +234,21 @@ struct BroadcastControlsOverlay: View {
|
|||||||
action: { pendingConfirm = .pauseOrResume },
|
action: { pendingConfirm = .pauseOrResume },
|
||||||
highlighted: isPaused
|
highlighted: isPaused
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("broadcast.pause")
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
systemName: audioMuted ? "speaker.slash.fill" : "speaker.wave.2.fill",
|
||||||
accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"),
|
accessibilityLabel: audioMuted ? L10n.t("broadcast.unmute.cd") : L10n.t("broadcast.mute.cd"),
|
||||||
action: { pendingConfirm = .toggleMute },
|
action: { pendingConfirm = .toggleMute },
|
||||||
highlighted: audioMuted
|
highlighted: audioMuted
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("broadcast.mute")
|
||||||
SideIconButton(
|
SideIconButton(
|
||||||
systemName: "stop.fill",
|
systemName: "stop.fill",
|
||||||
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
|
accessibilityLabel: L10n.t("broadcast.terminate.cd"),
|
||||||
action: { pendingConfirm = .terminate },
|
action: { pendingConfirm = .terminate },
|
||||||
danger: true
|
danger: true
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("broadcast.terminate")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -510,10 +516,12 @@ private struct TeamScoreColumn: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
||||||
|
.accessibilityIdentifier("broadcast.score.away.plus")
|
||||||
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
||||||
} else {
|
} else {
|
||||||
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
ScoreIconButton(label: "−", tooltip: L10n.t("broadcast.tooltip.remove.point", teamSide), action: onMinus)
|
||||||
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
ScoreIconButton(label: "+1", tooltip: L10n.t("broadcast.tooltip.add.point", teamSide), action: onPlus, primary: !showBasketButtons)
|
||||||
|
.accessibilityIdentifier(alignEnd ? "broadcast.score.away.plus" : "broadcast.score.home.plus")
|
||||||
if showBasketButtons {
|
if showBasketButtons {
|
||||||
if let onPlus2 {
|
if let onPlus2 {
|
||||||
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
|
ScoreIconButton(label: "+2", tooltip: L10n.t("broadcast.tooltip.plus.side", 2, teamSide), action: onPlus2, primary: true)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ struct ForgotPasswordScreen: View {
|
|||||||
Image(systemName: "chevron.left")
|
Image(systemName: "chevron.left")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("forgot.back")
|
||||||
Text(L10n.t("forgot.password.title"))
|
Text(L10n.t("forgot.password.title"))
|
||||||
.font(MatchTypography.headlineMedium)
|
.font(MatchTypography.headlineMedium)
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
@@ -64,6 +65,7 @@ struct ForgotPasswordScreen: View {
|
|||||||
enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success,
|
enabled: !email.trimmingCharacters(in: .whitespaces).isEmpty && !loading && !success,
|
||||||
loading: loading
|
loading: loading
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("forgot.submit")
|
||||||
}
|
}
|
||||||
.padding(24)
|
.padding(24)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ struct LoginScreen: View {
|
|||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("language.label"))
|
.accessibilityLabel(L10n.t("language.label"))
|
||||||
|
.accessibilityIdentifier("login.language")
|
||||||
}
|
}
|
||||||
.padding(.top, 8)
|
.padding(.top, 8)
|
||||||
MatchLiveWordmark(showSlogan: true)
|
MatchLiveWordmark(showSlogan: true)
|
||||||
@@ -57,6 +58,7 @@ struct LoginScreen: View {
|
|||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
.underline()
|
.underline()
|
||||||
}
|
}
|
||||||
|
.accessibilityIdentifier("login.forgot")
|
||||||
}
|
}
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
if let error {
|
if let error {
|
||||||
@@ -71,6 +73,7 @@ struct LoginScreen: View {
|
|||||||
enabled: !email.isEmpty && !password.isEmpty,
|
enabled: !email.isEmpty && !password.isEmpty,
|
||||||
loading: loading
|
loading: loading
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("login.submit")
|
||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 24)
|
.padding(.horizontal, 24)
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ struct MatchesScreen: View {
|
|||||||
@State private var showSchedule = false
|
@State private var showSchedule = false
|
||||||
@State private var showTeamPicker = false
|
@State private var showTeamPicker = false
|
||||||
@State private var resumeMatch: Match?
|
@State private var resumeMatch: Match?
|
||||||
|
@State private var configureMatch: Match?
|
||||||
|
@State private var pendingConfigureMatch: Match?
|
||||||
@State private var deleteMatch: Match?
|
@State private var deleteMatch: Match?
|
||||||
@State private var snackbar: String?
|
@State private var snackbar: String?
|
||||||
@State private var showLanguagePicker = false
|
@State private var showLanguagePicker = false
|
||||||
@@ -35,15 +37,19 @@ struct MatchesScreen: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "globe")
|
Image(systemName: "globe")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("language.label"))
|
.accessibilityLabel(L10n.t("language.label"))
|
||||||
|
.accessibilityIdentifier("matches.language")
|
||||||
Button {
|
Button {
|
||||||
onOpenAccount()
|
onOpenAccount()
|
||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "person.crop.circle")
|
Image(systemName: "person.crop.circle")
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(minWidth: 44, minHeight: 44)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("account.title"))
|
.accessibilityLabel(L10n.t("account.title"))
|
||||||
|
.accessibilityIdentifier("matches.account")
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 16)
|
.padding(.horizontal, 16)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
@@ -91,6 +97,7 @@ struct MatchesScreen: View {
|
|||||||
.font(MatchTypography.bodyMedium)
|
.font(MatchTypography.bodyMedium)
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
|
MatchPrimaryButton(label: L10n.t("matches.new").uppercased(), action: { showNewMatch = true })
|
||||||
|
.accessibilityIdentifier("matches.new")
|
||||||
if let activeTeam {
|
if let activeTeam {
|
||||||
TeamPickerBar(
|
TeamPickerBar(
|
||||||
team: activeTeam,
|
team: activeTeam,
|
||||||
@@ -151,14 +158,16 @@ struct MatchesScreen: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
||||||
.alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
.alert(
|
||||||
Button(L10n.t("sheet.resume.camera")) {
|
L10n.t("sheet.configure.now.title"),
|
||||||
if let match = resumeMatch { resumeBroadcast(match) }
|
isPresented: Binding(get: { configureMatch != nil }, set: { if !$0 { configureMatch = nil } })
|
||||||
resumeMatch = nil
|
) {
|
||||||
|
Button(L10n.t("sheet.configure")) {
|
||||||
|
if let match = configureMatch { onOpenSetup(match.id) }
|
||||||
|
configureMatch = nil
|
||||||
}
|
}
|
||||||
Button(L10n.t("sheet.configure"), role: .cancel) {
|
Button(L10n.t("sheet.later"), role: .cancel) {
|
||||||
if let match = resumeMatch { onOpenSetup(match.id) }
|
configureMatch = nil
|
||||||
resumeMatch = nil
|
|
||||||
}
|
}
|
||||||
} message: {
|
} message: {
|
||||||
Text(L10n.t("sheet.configure.now.body"))
|
Text(L10n.t("sheet.configure.now.body"))
|
||||||
@@ -201,10 +210,19 @@ struct MatchesScreen: View {
|
|||||||
.presentationDetents([.medium])
|
.presentationDetents([.medium])
|
||||||
.presentationDragIndicator(.visible)
|
.presentationDragIndicator(.visible)
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showSchedule) {
|
.sheet(isPresented: $showSchedule, onDismiss: {
|
||||||
|
if let match = pendingConfigureMatch {
|
||||||
|
pendingConfigureMatch = nil
|
||||||
|
configureMatch = match
|
||||||
|
}
|
||||||
|
}) {
|
||||||
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
|
ScheduleMatchSheet(container: container, teamId: activeTeam?.id) { match in
|
||||||
|
pendingConfigureMatch = match
|
||||||
|
snackbar = L10n.t("matches.msg.scheduled.ok")
|
||||||
|
reload(showSpinner: false)
|
||||||
showSchedule = false
|
showSchedule = false
|
||||||
onOpenSetup(match.id)
|
} onFailed: { error in
|
||||||
|
snackbar = UserFacingError.message(for: error) ?? L10n.t("matches.msg.schedule.error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.sheet(isPresented: $showTeamPicker) {
|
.sheet(isPresented: $showTeamPicker) {
|
||||||
@@ -229,6 +247,29 @@ struct MatchesScreen: View {
|
|||||||
ProgressView().tint(MatchColors.primaryRed)
|
ProgressView().tint(MatchColors.primaryRed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.overlay {
|
||||||
|
if let match = resumeMatch {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
Color.black.opacity(0.4)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onTapGesture { resumeMatch = nil }
|
||||||
|
ResumeSessionSheet(
|
||||||
|
match: match,
|
||||||
|
onResumeCamera: {
|
||||||
|
resumeMatch = nil
|
||||||
|
resumeBroadcast(match)
|
||||||
|
},
|
||||||
|
onContinueSetup: {
|
||||||
|
resumeMatch = nil
|
||||||
|
onOpenSetup(match.id)
|
||||||
|
},
|
||||||
|
onDismiss: { resumeMatch = nil }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea(edges: .bottom)
|
||||||
|
.accessibilityIdentifier("resume.sheet")
|
||||||
|
}
|
||||||
|
}
|
||||||
.overlay(alignment: .bottom) {
|
.overlay(alignment: .bottom) {
|
||||||
if let snackbar {
|
if let snackbar {
|
||||||
Text(snackbar)
|
Text(snackbar)
|
||||||
@@ -379,6 +420,7 @@ private struct ActiveSessionBanner: View {
|
|||||||
.background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
|
.background(MatchColors.primaryRed.opacity(0.15), in: RoundedRectangle(cornerRadius: 12))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("matches.resume.banner")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,12 +565,14 @@ private struct NewMatchSheet: View {
|
|||||||
subtitle: L10n.t("sheet.schedule.option.sub"),
|
subtitle: L10n.t("sheet.schedule.option.sub"),
|
||||||
action: onSchedule
|
action: onSchedule
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("sheet.schedule.option")
|
||||||
SheetOptionTile(
|
SheetOptionTile(
|
||||||
systemImage: "play.circle",
|
systemImage: "play.circle",
|
||||||
title: L10n.t("sheet.quick.option"),
|
title: L10n.t("sheet.quick.option"),
|
||||||
subtitle: L10n.t("sheet.quick.option.sub"),
|
subtitle: L10n.t("sheet.quick.option.sub"),
|
||||||
action: onQuickStart
|
action: onQuickStart
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("sheet.quick.option")
|
||||||
}
|
}
|
||||||
.padding(.top, 20)
|
.padding(.top, 20)
|
||||||
Spacer(minLength: 24)
|
Spacer(minLength: 24)
|
||||||
@@ -570,14 +614,68 @@ private struct SheetOptionTile: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private struct ResumeSessionSheet: View {
|
||||||
|
let match: Match
|
||||||
|
let onResumeCamera: () -> Void
|
||||||
|
let onContinueSetup: () -> Void
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
Text(L10n.t("sheet.live.in.progress"))
|
||||||
|
.font(MatchTypography.headlineMedium)
|
||||||
|
.accessibilityIdentifier("resume.sheet.title")
|
||||||
|
Text("\(match.teamName) vs \(match.opponentName)")
|
||||||
|
.font(MatchTypography.bodyMedium)
|
||||||
|
.padding(.top, 4)
|
||||||
|
if let status = match.activeSessionStatus, !status.isEmpty {
|
||||||
|
Text(L10n.t("sheet.status", status))
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(MatchColors.primaryRed)
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
VStack(spacing: 8) {
|
||||||
|
if match.canResumeCamera {
|
||||||
|
MatchPrimaryButton(label: L10n.t("sheet.resume.camera"), action: onResumeCamera)
|
||||||
|
.accessibilityIdentifier("resume.camera")
|
||||||
|
}
|
||||||
|
if match.activeSessionStatus == "idle" {
|
||||||
|
MatchSecondaryButton(label: L10n.t("sheet.continue.setup"), action: onContinueSetup)
|
||||||
|
.accessibilityIdentifier("resume.continue.setup")
|
||||||
|
}
|
||||||
|
Button(action: onDismiss) {
|
||||||
|
Text(L10n.t("action.cancel"))
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("resume.cancel")
|
||||||
|
}
|
||||||
|
.padding(.top, 20)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.top, 16)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||||
|
.background(MatchColors.surface)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
||||||
|
.accessibilityElement(children: .contain)
|
||||||
|
.accessibilityIdentifier("resume.sheet.panel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct ScheduleMatchSheet: View {
|
private struct ScheduleMatchSheet: View {
|
||||||
@ObservedObject var container: AppContainer
|
@ObservedObject var container: AppContainer
|
||||||
let teamId: String?
|
let teamId: String?
|
||||||
let onCreated: (Match) -> Void
|
let onCreated: (Match) -> Void
|
||||||
|
var onFailed: (Error) -> Void = { _ in }
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var opponent = ""
|
@State private var opponent = ""
|
||||||
@State private var location = ""
|
@State private var location = ""
|
||||||
@State private var date = Date().addingTimeInterval(86400)
|
@State private var date = Date().addingTimeInterval(86400)
|
||||||
|
@State private var saving = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
@@ -593,14 +691,15 @@ private struct ScheduleMatchSheet: View {
|
|||||||
}
|
}
|
||||||
ToolbarItem(placement: .confirmationAction) {
|
ToolbarItem(placement: .confirmationAction) {
|
||||||
Button(L10n.t("sheet.save.schedule")) { create() }
|
Button(L10n.t("sheet.save.schedule")) { create() }
|
||||||
.disabled(opponent.isEmpty || teamId == nil)
|
.disabled(opponent.isEmpty || teamId == nil || saving)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func create() {
|
private func create() {
|
||||||
guard let teamId else { return }
|
guard let teamId, !saving else { return }
|
||||||
|
saving = true
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
let match = try await container.matchRepository.createScheduledMatch(
|
let match = try await container.matchRepository.createScheduledMatch(
|
||||||
@@ -611,7 +710,8 @@ private struct ScheduleMatchSheet: View {
|
|||||||
)
|
)
|
||||||
onCreated(match)
|
onCreated(match)
|
||||||
} catch {
|
} catch {
|
||||||
// Sheet chiude solo su successo; errori gestiti dal chiamante via snackbar se necessario.
|
onFailed(error)
|
||||||
|
saving = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import PhotosUI
|
import PhotosUI
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
struct MatchCoverSection: View {
|
struct MatchCoverSection: View {
|
||||||
let effectiveCoverUrl: String?
|
let effectiveCoverUrl: String?
|
||||||
@@ -29,6 +30,7 @@ struct MatchCoverSection: View {
|
|||||||
.font(MatchTypography.bodyMedium)
|
.font(MatchTypography.bodyMedium)
|
||||||
.foregroundStyle(MatchColors.textSecondary)
|
.foregroundStyle(MatchColors.textSecondary)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
|
.accessibilityIdentifier("wizard.cover.source")
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
if let localCoverImage {
|
if let localCoverImage {
|
||||||
@@ -68,8 +70,20 @@ struct MatchCoverSection: View {
|
|||||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
|
.accessibilityIdentifier("wizard.cover.change")
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
|
if ProcessInfo.processInfo.arguments.contains("--uitesting-reset") {
|
||||||
|
Button("Usa copertina di test") {
|
||||||
|
localCoverImage = Self.makeUiTestCoverImage()
|
||||||
|
}
|
||||||
|
.font(MatchTypography.labelLarge)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.frame(height: 44)
|
||||||
|
.accessibilityIdentifier("wizard.cover.uitest")
|
||||||
|
.padding(.top, 8)
|
||||||
|
}
|
||||||
|
|
||||||
if hasMatchOverride || localCoverImage != nil {
|
if hasMatchOverride || localCoverImage != nil {
|
||||||
MatchSecondaryButton(
|
MatchSecondaryButton(
|
||||||
label: L10n.t("wizard.match.cover.reset"),
|
label: L10n.t("wizard.match.cover.reset"),
|
||||||
@@ -94,4 +108,14 @@ struct MatchCoverSection: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PNG 16:9 magenta, usata solo dai UI test (`--uitesting-reset`).
|
||||||
|
static func makeUiTestCoverImage() -> UIImage {
|
||||||
|
let size = CGSize(width: 1280, height: 720)
|
||||||
|
let renderer = UIGraphicsImageRenderer(size: size)
|
||||||
|
return renderer.image { ctx in
|
||||||
|
UIColor(red: 1, green: 0.125, blue: 0.75, alpha: 1).setFill()
|
||||||
|
ctx.fill(CGRect(origin: .zero, size: size))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,6 +224,7 @@ struct StepMatchScreen: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
MatchPrimaryButton(label: L10n.t("wizard.action.next"), action: saveAndContinue, enabled: !saving, loading: saving)
|
MatchPrimaryButton(label: L10n.t("wizard.action.next"), action: saveAndContinue, enabled: !saving, loading: saving)
|
||||||
|
.accessibilityIdentifier("wizard.next")
|
||||||
.padding(.top, 32)
|
.padding(.top, 32)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 20)
|
.padding(.horizontal, 20)
|
||||||
|
|||||||
@@ -92,7 +92,8 @@ struct StepNetworkTestScreen: View {
|
|||||||
if testCompleted, let shareUrl {
|
if testCompleted, let shareUrl {
|
||||||
WizardReadOnlyField(
|
WizardReadOnlyField(
|
||||||
label: currentSession.platform == "youtube" ? L10n.t("wizard.network.link.youtube.label") : L10n.t("wizard.network.link.live.label"),
|
label: currentSession.platform == "youtube" ? L10n.t("wizard.network.link.youtube.label") : L10n.t("wizard.network.link.live.label"),
|
||||||
value: shareUrl
|
value: shareUrl,
|
||||||
|
valueIdentifier: "wizard.network.shareurl"
|
||||||
)
|
)
|
||||||
.padding(.top, 16)
|
.padding(.top, 16)
|
||||||
|
|
||||||
@@ -125,6 +126,7 @@ struct StepNetworkTestScreen: View {
|
|||||||
action: runTest,
|
action: runTest,
|
||||||
enabled: !testing
|
enabled: !testing
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("wizard.network.test")
|
||||||
.padding(.top, 24)
|
.padding(.top, 24)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,12 +137,14 @@ struct StepNetworkTestScreen: View {
|
|||||||
HStack(spacing: spacing) {
|
HStack(spacing: spacing) {
|
||||||
MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack, enabled: !starting)
|
MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack, enabled: !starting)
|
||||||
.frame(width: backWidth)
|
.frame(width: backWidth)
|
||||||
|
.accessibilityIdentifier("wizard.back")
|
||||||
MatchPrimaryButton(
|
MatchPrimaryButton(
|
||||||
label: L10n.t("wizard.action.start"),
|
label: L10n.t("wizard.action.start"),
|
||||||
action: startLive,
|
action: startLive,
|
||||||
enabled: ready,
|
enabled: ready,
|
||||||
loading: starting
|
loading: starting
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("wizard.start")
|
||||||
.frame(width: forwardWidth)
|
.frame(width: forwardWidth)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ struct StepTransmissionScreen: View {
|
|||||||
|
|
||||||
@State private var team: Team?
|
@State private var team: Team?
|
||||||
@State private var platform = "matchlivetv"
|
@State private var platform = "matchlivetv"
|
||||||
@State private var privacy = "public"
|
@State private var privacy = ProcessInfo.processInfo.arguments.contains("--uitesting-reset") ? "unlisted" : "public"
|
||||||
@State private var creating = false
|
@State private var creating = false
|
||||||
|
|
||||||
private var youtubeReady: Bool {
|
private var youtubeReady: Bool {
|
||||||
@@ -42,6 +42,7 @@ struct StepTransmissionScreen: View {
|
|||||||
selected: platform == "matchlivetv",
|
selected: platform == "matchlivetv",
|
||||||
onClick: { platform = "matchlivetv" }
|
onClick: { platform = "matchlivetv" }
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("wizard.platform.site")
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
WizardPlatformCard(
|
WizardPlatformCard(
|
||||||
@@ -70,6 +71,7 @@ struct StepTransmissionScreen: View {
|
|||||||
selected: privacy == "unlisted",
|
selected: privacy == "unlisted",
|
||||||
action: { privacy = "unlisted" }
|
action: { privacy = "unlisted" }
|
||||||
)
|
)
|
||||||
|
.accessibilityIdentifier("wizard.privacy.unlisted")
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ struct TeamBrandingRow: View {
|
|||||||
.font(MatchTypography.titleMedium)
|
.font(MatchTypography.titleMedium)
|
||||||
.padding(8)
|
.padding(8)
|
||||||
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
.overlay(RoundedRectangle(cornerRadius: 8).stroke(MatchColors.outline, lineWidth: 1))
|
||||||
|
.accessibilityIdentifier("wizard.opponent")
|
||||||
} else {
|
} else {
|
||||||
Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName)
|
Text(teamName.isEmpty ? L10n.t("wizard.branding.team.fallback.name") : teamName)
|
||||||
.font(MatchTypography.titleMedium)
|
.font(MatchTypography.titleMedium)
|
||||||
|
|||||||
@@ -37,9 +37,11 @@ struct WizardFooterButtons: View {
|
|||||||
if showBack {
|
if showBack {
|
||||||
MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack)
|
MatchSecondaryButton(label: L10n.t("wizard.action.back"), action: onBack)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.accessibilityIdentifier("wizard.back")
|
||||||
}
|
}
|
||||||
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
|
MatchPrimaryButton(label: forwardLabel, action: onForward, loading: forwardLoading)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity)
|
||||||
|
.accessibilityIdentifier("wizard.next")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,11 +68,18 @@ struct WizardOutlinedField: View {
|
|||||||
struct WizardReadOnlyField: View {
|
struct WizardReadOnlyField: View {
|
||||||
let label: String
|
let label: String
|
||||||
let value: String
|
let value: String
|
||||||
|
var valueIdentifier: String? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text(label).font(MatchTypography.bodyMedium)
|
Text(label).font(MatchTypography.bodyMedium)
|
||||||
Text(value).font(MatchTypography.titleMedium)
|
if let valueIdentifier {
|
||||||
|
Text(value)
|
||||||
|
.font(MatchTypography.titleMedium)
|
||||||
|
.accessibilityIdentifier(valueIdentifier)
|
||||||
|
} else {
|
||||||
|
Text(value).font(MatchTypography.titleMedium)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(14)
|
.padding(14)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ struct WizardShellScreen: View {
|
|||||||
Image(systemName: "xmark").foregroundStyle(.white)
|
Image(systemName: "xmark").foregroundStyle(.white)
|
||||||
}
|
}
|
||||||
.accessibilityLabel(L10n.t("common.close"))
|
.accessibilityLabel(L10n.t("common.close"))
|
||||||
|
.accessibilityIdentifier("wizard.close")
|
||||||
Text(wizardStepTitle(currentStep))
|
Text(wizardStepTitle(currentStep))
|
||||||
.font(MatchTypography.titleMedium)
|
.font(MatchTypography.titleMedium)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
|
|||||||
@@ -0,0 +1,727 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
/// Pilota il simulatore come un utente (backend da API_BASE_URL).
|
||||||
|
final class HubFlowsUITests: XCTestCase {
|
||||||
|
private var app: XCUIApplication!
|
||||||
|
private let shotDir = "/tmp/mltv-uitest"
|
||||||
|
private var apiBaseUrl = "http://localhost:3000"
|
||||||
|
|
||||||
|
override func setUpWithError() throws {
|
||||||
|
continueAfterFailure = false
|
||||||
|
addUIInterruptionMonitor(withDescription: "Dialog di sistema") { alert in
|
||||||
|
for label in [
|
||||||
|
"Non ora", "Not Now", "Not now",
|
||||||
|
"Consenti", "OK", "Allow", "Allow Once", "Consenti una volta",
|
||||||
|
"Allow While Using App", "Consenti durante l'utilizzo",
|
||||||
|
"Allow While Using the App"
|
||||||
|
] {
|
||||||
|
if alert.buttons[label].exists {
|
||||||
|
alert.buttons[label].tap()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
try FileManager.default.createDirectory(atPath: shotDir, withIntermediateDirectories: true)
|
||||||
|
app = XCUIApplication()
|
||||||
|
// L'URL API arriva dall'Info.plist dell'app ($(API_BASE_URL) di xcodebuild).
|
||||||
|
// Non iniettare un default localhost: maschererebbe il collaudo.
|
||||||
|
if let fromEnv = ProcessInfo.processInfo.environment["API_BASE_URL"]?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||||
|
!fromEnv.isEmpty {
|
||||||
|
apiBaseUrl = fromEnv
|
||||||
|
app.launchEnvironment["API_BASE_URL"] = fromEnv
|
||||||
|
}
|
||||||
|
app.launchArguments = ["--uitesting-reset", "-AppleLanguages", "(it)", "-AppleLocale", "it_IT"]
|
||||||
|
app.launch()
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLanguagesOnLogin() throws {
|
||||||
|
waitLogin()
|
||||||
|
dismissKeyboard()
|
||||||
|
capture("lang-00-it-login")
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["ACCEDI"].exists || app.buttons["ACCEDI"].exists,
|
||||||
|
"Login italiano atteso (ACCEDI)"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(any("login.forgot").exists || app.buttons["Password dimenticata?"].exists)
|
||||||
|
|
||||||
|
assertLanguageSwitch(
|
||||||
|
code: "en",
|
||||||
|
loginTitle: "LOG IN",
|
||||||
|
forgot: "Forgot password?",
|
||||||
|
shot: "lang-01-en-login"
|
||||||
|
)
|
||||||
|
assertLanguageSwitch(
|
||||||
|
code: "fr",
|
||||||
|
loginTitle: "CONNEXION",
|
||||||
|
forgot: "Mot de passe oublié ?",
|
||||||
|
shot: "lang-02-fr-login"
|
||||||
|
)
|
||||||
|
assertLanguageSwitch(
|
||||||
|
code: "de",
|
||||||
|
loginTitle: "ANMELDEN",
|
||||||
|
forgot: "Passwort vergessen?",
|
||||||
|
shot: "lang-03-de-login"
|
||||||
|
)
|
||||||
|
assertLanguageSwitch(
|
||||||
|
code: "es",
|
||||||
|
loginTitle: "ACCEDER",
|
||||||
|
forgot: "¿Olvidaste la contraseña?",
|
||||||
|
shot: "lang-04-es-login"
|
||||||
|
)
|
||||||
|
assertLanguageSwitch(
|
||||||
|
code: "it",
|
||||||
|
loginTitle: "ACCEDI",
|
||||||
|
forgot: "Password dimenticata?",
|
||||||
|
shot: "lang-05-it-login"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testCoachFlows() throws {
|
||||||
|
waitLogin()
|
||||||
|
dismissKeyboard()
|
||||||
|
capture("01-login")
|
||||||
|
|
||||||
|
let forgot = firstExisting(["login.forgot"], labels: ["Password dimenticata?", "Forgot password?"])
|
||||||
|
XCTAssertTrue(forgot.waitForExistence(timeout: 8), "Link password dimenticata assente")
|
||||||
|
tapHittable(forgot)
|
||||||
|
XCTAssertTrue(any("forgot.back").waitForExistence(timeout: 8), "Schermata password dimenticata assente")
|
||||||
|
capture("02-forgot-password")
|
||||||
|
|
||||||
|
let mail = app.textFields.firstMatch
|
||||||
|
XCTAssertTrue(mail.waitForExistence(timeout: 4))
|
||||||
|
tapHittable(mail)
|
||||||
|
mail.typeText("coach@matchlivetv.test")
|
||||||
|
dismissKeyboard()
|
||||||
|
tapHittable(firstExisting(["forgot.submit"], labels: ["Invia link di reset", "Send reset link"]))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'link'")).firstMatch.waitForExistence(timeout: 12)
|
||||||
|
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'registrat'")).firstMatch.exists,
|
||||||
|
"Manca il messaggio di esito del reset password.\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
capture("02b-forgot-submitted")
|
||||||
|
tapHittable(app.buttons["forgot.back"])
|
||||||
|
|
||||||
|
loginIfNeeded()
|
||||||
|
waitHub()
|
||||||
|
dismissSystemDialogs()
|
||||||
|
capture("03-hub-dopo-login")
|
||||||
|
|
||||||
|
openLanguagePicker(from: "matches.language")
|
||||||
|
tapHittable(any("language.en"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.buttons["NEW MATCH"].waitForExistence(timeout: 8) || any("matches.new").waitForExistence(timeout: 4),
|
||||||
|
"Hub non passato in inglese"
|
||||||
|
)
|
||||||
|
capture("03b-hub-en")
|
||||||
|
openLanguagePicker(from: "matches.language")
|
||||||
|
tapHittable(any("language.it"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.buttons["NUOVA PARTITA"].waitForExistence(timeout: 8) || any("matches.new").waitForExistence(timeout: 4),
|
||||||
|
"Hub non tornato in italiano"
|
||||||
|
)
|
||||||
|
|
||||||
|
openAccountAndBack()
|
||||||
|
capture("04-hub-dopo-account")
|
||||||
|
|
||||||
|
scheduleMatchShowsConfigureDialog()
|
||||||
|
capture("05-dopo-programmazione")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testLogoutReturnsToLogin() throws {
|
||||||
|
loginIfNeeded()
|
||||||
|
waitHub()
|
||||||
|
dismissSystemDialogs()
|
||||||
|
tapHittable(firstExisting(["matches.account"], labels: ["Account"]))
|
||||||
|
XCTAssertTrue(any("account.back").waitForExistence(timeout: 8), "Account non aperto")
|
||||||
|
app.swipeUp()
|
||||||
|
app.swipeUp()
|
||||||
|
XCTAssertTrue(any("account.logout").waitForExistence(timeout: 6), "Bottone Esci assente")
|
||||||
|
tapHittable(any("account.logout"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
any("login.submit").waitForExistence(timeout: 12) || app.staticTexts["ACCEDI"].waitForExistence(timeout: 4),
|
||||||
|
"Logout non ha riportato al login"
|
||||||
|
)
|
||||||
|
capture("14-dopo-logout")
|
||||||
|
}
|
||||||
|
|
||||||
|
func testWizardNetworkAndLive() throws {
|
||||||
|
loginIfNeeded()
|
||||||
|
waitHub()
|
||||||
|
dismissSystemDialogs()
|
||||||
|
tapNewMatch()
|
||||||
|
let quick = firstExisting(["sheet.quick.option"], labels: ["Avvia subito"])
|
||||||
|
XCTAssertTrue(quick.waitForExistence(timeout: 8))
|
||||||
|
tapHittable(quick)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["01 · Partita"].waitForExistence(timeout: 25),
|
||||||
|
"Wizard step 1 assente"
|
||||||
|
)
|
||||||
|
capture("06-wizard-step1")
|
||||||
|
|
||||||
|
let opponent = any("wizard.opponent").exists ? any("wizard.opponent") : app.textFields.firstMatch
|
||||||
|
if opponent.waitForExistence(timeout: 8) {
|
||||||
|
tapHittable(opponent)
|
||||||
|
if let current = opponent.value as? String, !current.isEmpty {
|
||||||
|
let deletes = String(repeating: XCUIKeyboardKey.delete.rawValue, count: current.count)
|
||||||
|
opponent.typeText(deletes)
|
||||||
|
}
|
||||||
|
opponent.typeText("UI Wizard")
|
||||||
|
dismissKeyboard()
|
||||||
|
}
|
||||||
|
applyMatchCover()
|
||||||
|
scrollToId("wizard.next")
|
||||||
|
tapHittable(any("wizard.next"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["02 · Trasmissione"].waitForExistence(timeout: 20)
|
||||||
|
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Trasmissione' OR label CONTAINS 'Transmission'")).firstMatch.waitForExistence(timeout: 8),
|
||||||
|
"Wizard step 2 (trasmissione) non aperto.\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
capture("07-wizard-step2")
|
||||||
|
if any("wizard.platform.site").waitForExistence(timeout: 4) {
|
||||||
|
tapHittable(any("wizard.platform.site"))
|
||||||
|
}
|
||||||
|
let unlisted = firstExisting(["wizard.privacy.unlisted"], labels: ["NON IN ELENCO", "UNLISTED"])
|
||||||
|
XCTAssertTrue(unlisted.waitForExistence(timeout: 6), "Manca l'opzione NON IN ELENCO")
|
||||||
|
tapHittable(unlisted)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Solo chi ha il link' OR label CONTAINS 'Only those with the link'")).firstMatch.waitForExistence(timeout: 4)
|
||||||
|
|| any("wizard.privacy.unlisted").exists,
|
||||||
|
"Visibilità non impostata su privato/unlisted"
|
||||||
|
)
|
||||||
|
capture("07b-privacy-unlisted")
|
||||||
|
scrollToId("wizard.next")
|
||||||
|
tapHittable(any("wizard.next"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["03 · Test rete"].waitForExistence(timeout: 20)
|
||||||
|
|| any("wizard.network.test").waitForExistence(timeout: 10),
|
||||||
|
"Wizard step 3 (rete) non aperto.\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
capture("08-wizard-step3")
|
||||||
|
|
||||||
|
scrollToId("wizard.network.test")
|
||||||
|
tapHittable(any("wizard.network.test"))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["PRONTO PER ANDARE IN DIRETTA"].waitForExistence(timeout: 20)
|
||||||
|
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'PRONTO' OR label CONTAINS 'READY'")).firstMatch.waitForExistence(timeout: 10),
|
||||||
|
"Test rete non completato.\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
capture("08b-network-ready")
|
||||||
|
let tree = app.debugDescription
|
||||||
|
XCTAssertFalse(tree.contains("youtube.com"), "Il test ha creato un video YouTube: deve restare sul sito, unlisted")
|
||||||
|
XCTAssertTrue(
|
||||||
|
tree.contains("matchlivetv.it/live") || tree.contains("/live/"),
|
||||||
|
"Manca il link diretta sul sito"
|
||||||
|
)
|
||||||
|
let sessionId = extractLiveSessionId()
|
||||||
|
XCTAssertNotNil(sessionId, "Impossibile leggere l'id sessione dal link diretta.\n\(tree)")
|
||||||
|
if let sessionId {
|
||||||
|
try? sessionId.write(toFile: "\(shotDir)/live-session-id.txt", atomically: true, encoding: .utf8)
|
||||||
|
assertLivePageIsUnlistedWithMatchCover(sessionId: sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
let start = any("wizard.start")
|
||||||
|
XCTAssertTrue(start.waitForExistence(timeout: 6))
|
||||||
|
tapHittable(start)
|
||||||
|
dismissSystemDialogs()
|
||||||
|
let liveVisible =
|
||||||
|
any("broadcast.terminate").waitForExistence(timeout: 25)
|
||||||
|
|| any("broadcast.pause").waitForExistence(timeout: 4)
|
||||||
|
|| app.buttons["CONCEDI PERMESSI"].waitForExistence(timeout: 4)
|
||||||
|
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'camera' OR label CONTAINS 'microfono' OR label CONTAINS 'Permessi'")).firstMatch.waitForExistence(timeout: 4)
|
||||||
|
capture("09-broadcast")
|
||||||
|
XCTAssertTrue(liveVisible, "Dopo INIZIA non si apre la diretta né la richiesta permessi.\n\(app.debugDescription)")
|
||||||
|
|
||||||
|
if any("broadcast.mute").waitForExistence(timeout: 3) {
|
||||||
|
tapHittable(any("broadcast.mute"))
|
||||||
|
sleep(1)
|
||||||
|
capture("09b-mute-confirm")
|
||||||
|
if app.buttons["Conferma"].waitForExistence(timeout: 2) || app.alerts.buttons.element(boundBy: 1).exists {
|
||||||
|
let confirm = app.buttons["Conferma"].exists ? app.buttons["Conferma"] : app.alerts.buttons.element(boundBy: 1)
|
||||||
|
tapHittable(confirm)
|
||||||
|
}
|
||||||
|
capture("09c-muted")
|
||||||
|
}
|
||||||
|
|
||||||
|
if any("broadcast.score.home.plus").waitForExistence(timeout: 3) {
|
||||||
|
tapHittable(any("broadcast.score.home.plus"))
|
||||||
|
sleep(1)
|
||||||
|
capture("09c2-score-plus")
|
||||||
|
}
|
||||||
|
if any("broadcast.abr").waitForExistence(timeout: 2) {
|
||||||
|
tapHittable(any("broadcast.abr"))
|
||||||
|
sleep(1)
|
||||||
|
capture("09c3-abr")
|
||||||
|
if app.buttons["Annulla"].waitForExistence(timeout: 2) {
|
||||||
|
tapHittable(app.buttons["Annulla"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if any("broadcast.share").waitForExistence(timeout: 2) {
|
||||||
|
tapHittable(any("broadcast.share"))
|
||||||
|
sleep(1)
|
||||||
|
if app.buttons["Conferma"].waitForExistence(timeout: 3) {
|
||||||
|
tapHittable(app.buttons["Conferma"])
|
||||||
|
}
|
||||||
|
sleep(1)
|
||||||
|
capture("09c4-share")
|
||||||
|
let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
for label in ["Chiudi", "Close", "Annulla", "Cancel"] {
|
||||||
|
if spring.buttons[label].exists {
|
||||||
|
spring.buttons[label].tap()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if app.buttons[label].exists {
|
||||||
|
tapHittable(app.buttons[label])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if any("broadcast.pause").waitForExistence(timeout: 4) {
|
||||||
|
tapHittable(any("broadcast.pause"))
|
||||||
|
sleep(1)
|
||||||
|
capture("09c5-pause-confirm")
|
||||||
|
let confirmPause = firstExisting([], labels: ["Conferma", "Confirm"])
|
||||||
|
if confirmPause.waitForExistence(timeout: 3) {
|
||||||
|
tapHittable(confirmPause)
|
||||||
|
}
|
||||||
|
sleep(3)
|
||||||
|
capture("09c6-paused")
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["PAUSA"].waitForExistence(timeout: 8)
|
||||||
|
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'PAUSA' OR label CONTAINS 'PAUSED'")).firstMatch.exists,
|
||||||
|
"Dopo la conferma non compare lo stato PAUSA"
|
||||||
|
)
|
||||||
|
if let sessionId {
|
||||||
|
assertViewerShowsCoverWhilePaused(sessionId: sessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if any("broadcast.terminate").waitForExistence(timeout: 3) {
|
||||||
|
tapHittable(any("broadcast.terminate"))
|
||||||
|
sleep(1)
|
||||||
|
capture("09d-terminate-confirm")
|
||||||
|
for label in ["TERMINA", "END", "BEENDEN", "TERMINAR", "TERMINER"] {
|
||||||
|
if app.buttons[label].exists {
|
||||||
|
tapHittable(app.buttons[label])
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if any("wizard.close").waitForExistence(timeout: 2) {
|
||||||
|
tapHittable(any("wizard.close"))
|
||||||
|
}
|
||||||
|
_ = any("matches.new").waitForExistence(timeout: 12)
|
||||||
|
capture("09e-back-hub")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func applyMatchCover() {
|
||||||
|
scrollToId("wizard.cover.change")
|
||||||
|
let change = firstExisting(["wizard.cover.change"], labels: ["Cambia copertina per questa partita"])
|
||||||
|
XCTAssertTrue(change.waitForExistence(timeout: 8), "Manca Cambia copertina per questa partita")
|
||||||
|
|
||||||
|
scrollToId("wizard.cover.uitest")
|
||||||
|
let testCover = any("wizard.cover.uitest")
|
||||||
|
XCTAssertTrue(testCover.waitForExistence(timeout: 6), "Manca il fallback copertina di test")
|
||||||
|
tapHittable(testCover)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts.matching(
|
||||||
|
NSPredicate(format: "label CONTAINS 'Questa partita' OR label CONTAINS 'This match'")
|
||||||
|
).firstMatch.waitForExistence(timeout: 8),
|
||||||
|
"La copertina di test non è stata applicata a questa partita.\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
capture("06c-cover-match")
|
||||||
|
|
||||||
|
tapHittable(change)
|
||||||
|
sleep(2)
|
||||||
|
capture("06b-cover-picker")
|
||||||
|
_ = pickFirstPhotoFromSystemPicker()
|
||||||
|
dismissPhotoPicker()
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts.matching(
|
||||||
|
NSPredicate(format: "label CONTAINS 'Questa partita' OR label CONTAINS 'This match'")
|
||||||
|
).firstMatch.waitForExistence(timeout: 6),
|
||||||
|
"Dopo il selettore foto la copertina della partita è sparita"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
private func pickFirstPhotoFromSystemPicker() -> Bool {
|
||||||
|
let photos = XCUIApplication(bundleIdentifier: "com.apple.PhotosUIService")
|
||||||
|
guard photos.wait(for: .runningForeground, timeout: 5) else { return false }
|
||||||
|
let candidates: [XCUIElement] = [
|
||||||
|
photos.images.firstMatch,
|
||||||
|
photos.collectionViews.cells.firstMatch,
|
||||||
|
photos.cells.firstMatch
|
||||||
|
]
|
||||||
|
for el in candidates {
|
||||||
|
if el.waitForExistence(timeout: 2) {
|
||||||
|
el.tap()
|
||||||
|
sleep(1)
|
||||||
|
for label in ["Aggiungi", "Add", "Scegli", "Choose", "Done", "Fine"] {
|
||||||
|
if photos.buttons[label].exists {
|
||||||
|
photos.buttons[label].tap()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let source = app.staticTexts.matching(
|
||||||
|
NSPredicate(format: "label CONTAINS 'Questa partita' OR label CONTAINS 'This match'")
|
||||||
|
).firstMatch
|
||||||
|
if source.waitForExistence(timeout: 4) { return true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissPhotoPicker() {
|
||||||
|
let photos = XCUIApplication(bundleIdentifier: "com.apple.PhotosUIService")
|
||||||
|
let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
for label in ["Annulla", "Cancel", "Chiudi", "Close"] {
|
||||||
|
if photos.buttons[label].exists {
|
||||||
|
photos.buttons[label].tap()
|
||||||
|
sleep(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if spring.buttons[label].exists {
|
||||||
|
spring.buttons[label].tap()
|
||||||
|
sleep(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if app.buttons[label].exists {
|
||||||
|
tapHittable(app.buttons[label])
|
||||||
|
sleep(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if photos.state == .runningForeground {
|
||||||
|
photos.swipeDown()
|
||||||
|
sleep(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractLiveSessionId() -> String? {
|
||||||
|
let share = any("wizard.network.shareurl")
|
||||||
|
let raw = share.exists ? share.label : app.debugDescription
|
||||||
|
guard let range = raw.range(of: #"https?://[^\s]+/live/[0-9a-fA-F-]{36}"#, options: .regularExpression) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let url = String(raw[range])
|
||||||
|
return url.components(separatedBy: "/live/").last
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertLivePageIsUnlistedWithMatchCover(sessionId: String) {
|
||||||
|
let pageURL = "https://www.matchlivetv.it/live/\(sessionId)"
|
||||||
|
let deadline = Date().addingTimeInterval(40)
|
||||||
|
var html = ""
|
||||||
|
var poster = ""
|
||||||
|
while Date() < deadline {
|
||||||
|
html = httpGet(pageURL) ?? ""
|
||||||
|
if html.contains("youtube.com/watch") {
|
||||||
|
XCTFail("La pagina pubblica punta a YouTube invece che al sito")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let range = html.range(of: #"poster="([^"]+)""#, options: .regularExpression) {
|
||||||
|
poster = String(html[range])
|
||||||
|
.replacingOccurrences(of: "poster=\"", with: "")
|
||||||
|
.replacingOccurrences(of: "\"", with: "")
|
||||||
|
}
|
||||||
|
let unlisted = html.contains("noindex")
|
||||||
|
let customPoster = !poster.isEmpty && !poster.contains("copertina-canale")
|
||||||
|
if unlisted && customPoster { break }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(2))
|
||||||
|
}
|
||||||
|
XCTAssertTrue(html.contains("noindex") || html.contains("nofollow"), "La diretta deve restare non in elenco (noindex)")
|
||||||
|
XCTAssertFalse(poster.isEmpty, "Manca l'attributo poster sul player live")
|
||||||
|
XCTAssertFalse(poster.contains("copertina-canale"), "Il poster è ancora la copertina di default: \(poster)")
|
||||||
|
try? html.write(toFile: "\(shotDir)/live-page.html", atomically: true, encoding: .utf8)
|
||||||
|
try? poster.write(toFile: "\(shotDir)/live-poster.txt", atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertViewerShowsCoverWhilePaused(sessionId: String) {
|
||||||
|
let statusURL = "https://www.matchlivetv.it/live/\(sessionId)/status.json"
|
||||||
|
let deadline = Date().addingTimeInterval(25)
|
||||||
|
var payload: [String: Any] = [:]
|
||||||
|
while Date() < deadline {
|
||||||
|
payload = httpGetJSON(statusURL) ?? [:]
|
||||||
|
let paused = payload["paused"] as? Bool ?? false
|
||||||
|
let status = payload["status"] as? String ?? ""
|
||||||
|
let showingCover = payload["showing_cover"] as? Bool ?? false
|
||||||
|
if (paused || status == "paused") && showingCover {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(1.5))
|
||||||
|
}
|
||||||
|
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted]),
|
||||||
|
let text = String(data: data, encoding: .utf8) {
|
||||||
|
try? text.write(toFile: "\(shotDir)/live-status-paused.json", atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
let paused = payload["paused"] as? Bool ?? false
|
||||||
|
let status = payload["status"] as? String ?? ""
|
||||||
|
let showingCover = payload["showing_cover"] as? Bool ?? false
|
||||||
|
XCTAssertTrue(paused || status == "paused", "status.json non è in pausa: \(payload)")
|
||||||
|
XCTAssertTrue(showingCover, "Gli spettatori non stanno vedendo la copertina (showing_cover=false): \(payload)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func httpGet(_ urlString: String) -> String? {
|
||||||
|
guard let url = URL(string: urlString) else { return nil }
|
||||||
|
let sem = DispatchSemaphore(value: 0)
|
||||||
|
var result: String?
|
||||||
|
URLSession.shared.dataTask(with: url) { data, _, _ in
|
||||||
|
if let data { result = String(data: data, encoding: .utf8) }
|
||||||
|
sem.signal()
|
||||||
|
}.resume()
|
||||||
|
_ = sem.wait(timeout: .now() + 15)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private func httpGetJSON(_ urlString: String) -> [String: Any]? {
|
||||||
|
guard let body = httpGet(urlString),
|
||||||
|
let data = body.data(using: .utf8),
|
||||||
|
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return json
|
||||||
|
}
|
||||||
|
|
||||||
|
func testResumeLiveSheet() throws {
|
||||||
|
loginIfNeeded()
|
||||||
|
waitHub()
|
||||||
|
dismissSystemDialogs()
|
||||||
|
capture("10-hub-con-banner")
|
||||||
|
|
||||||
|
let banner = app.buttons["matches.resume.banner"]
|
||||||
|
if !banner.waitForExistence(timeout: 10) {
|
||||||
|
throw XCTSkip("Nessuna sessione attiva da riprendere su \(apiBaseUrl)")
|
||||||
|
}
|
||||||
|
tapHittable(banner)
|
||||||
|
sleep(1)
|
||||||
|
capture("11-dopo-tap-banner")
|
||||||
|
|
||||||
|
let sheetVisible =
|
||||||
|
any("resume.sheet").waitForExistence(timeout: 4)
|
||||||
|
|| any("resume.sheet.panel").waitForExistence(timeout: 2)
|
||||||
|
|| any("resume.camera").waitForExistence(timeout: 2)
|
||||||
|
|| app.staticTexts["Diretta in corso"].waitForExistence(timeout: 2)
|
||||||
|
XCTAssertTrue(sheetVisible, "Lo sheet di ripresa non è comparso.\n\(app.debugDescription)")
|
||||||
|
capture("12-resume-sheet")
|
||||||
|
|
||||||
|
let cancel = firstExisting(["resume.cancel"], labels: ["Annulla", "Cancel"])
|
||||||
|
if cancel.waitForExistence(timeout: 3) {
|
||||||
|
tapHittable(cancel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func assertLanguageSwitch(code: String, loginTitle: String, forgot: String, shot: String) {
|
||||||
|
openLanguagePicker(from: "login.language")
|
||||||
|
let row = any("language.\(code)")
|
||||||
|
XCTAssertTrue(row.waitForExistence(timeout: 6), "Voce lingua \(code) assente")
|
||||||
|
tapHittable(row)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts[loginTitle].waitForExistence(timeout: 8) || app.buttons[loginTitle].waitForExistence(timeout: 2),
|
||||||
|
"Dopo lingua \(code) manca il titolo \(loginTitle).\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
XCTAssertTrue(
|
||||||
|
any("login.forgot").waitForExistence(timeout: 4)
|
||||||
|
|| app.buttons[forgot].waitForExistence(timeout: 2)
|
||||||
|
|| app.staticTexts[forgot].waitForExistence(timeout: 1),
|
||||||
|
"Dopo lingua \(code) manca '\(forgot)'"
|
||||||
|
)
|
||||||
|
capture(shot)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openLanguagePicker(from id: String) {
|
||||||
|
let globe = any(id)
|
||||||
|
XCTAssertTrue(globe.waitForExistence(timeout: 8), "Bottone lingua \(id) assente")
|
||||||
|
tapHittable(globe)
|
||||||
|
XCTAssertTrue(
|
||||||
|
any("language.it").waitForExistence(timeout: 8) || app.staticTexts["Italiano"].waitForExistence(timeout: 4),
|
||||||
|
"Picker lingua non aperto"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loginIfNeeded() {
|
||||||
|
if hubVisible() { return }
|
||||||
|
waitLogin()
|
||||||
|
dismissKeyboard()
|
||||||
|
let email = app.textFields.firstMatch
|
||||||
|
XCTAssertTrue(email.waitForExistence(timeout: 8))
|
||||||
|
tapHittable(email)
|
||||||
|
email.typeText("coach@matchlivetv.test")
|
||||||
|
let password = app.secureTextFields.firstMatch
|
||||||
|
XCTAssertTrue(password.waitForExistence(timeout: 4))
|
||||||
|
tapHittable(password)
|
||||||
|
password.typeText("Password123")
|
||||||
|
dismissKeyboard()
|
||||||
|
let submit = firstExisting(["login.submit"], labels: ["ACCEDI", "LOG IN", "CONNEXION", "ANMELDEN", "ACCEDER"])
|
||||||
|
XCTAssertTrue(submit.waitForExistence(timeout: 4))
|
||||||
|
tapHittable(submit)
|
||||||
|
dismissSystemDialogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitLogin() {
|
||||||
|
let deadline = Date().addingTimeInterval(20)
|
||||||
|
while Date() < deadline {
|
||||||
|
if any("login.submit").exists || app.textFields.firstMatch.exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if hubVisible() { return }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.3))
|
||||||
|
}
|
||||||
|
capture("fail-login")
|
||||||
|
XCTFail("Login non comparso. Gerarchia:\n\(app.debugDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func waitHub() {
|
||||||
|
XCTAssertTrue(
|
||||||
|
any("matches.new").waitForExistence(timeout: 25)
|
||||||
|
|| app.buttons["NUOVA PARTITA"].waitForExistence(timeout: 5)
|
||||||
|
|| app.buttons["NEW MATCH"].waitForExistence(timeout: 2),
|
||||||
|
"Hub partite non raggiunto dopo login su \(apiBaseUrl).\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
dismissKeyboard()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func hubVisible() -> Bool {
|
||||||
|
app.buttons["matches.new"].exists || app.buttons["NUOVA PARTITA"].exists || app.buttons["NEW MATCH"].exists
|
||||||
|
}
|
||||||
|
|
||||||
|
private func openAccountAndBack() {
|
||||||
|
let account = firstExisting(["matches.account"], labels: ["Account"])
|
||||||
|
XCTAssertTrue(account.waitForExistence(timeout: 6), "Bottone Account assente")
|
||||||
|
tapHittable(account)
|
||||||
|
XCTAssertTrue(
|
||||||
|
any("account.title").waitForExistence(timeout: 8) || any("account.back").waitForExistence(timeout: 2),
|
||||||
|
"Schermata Account non aperta.\n\(app.debugDescription)"
|
||||||
|
)
|
||||||
|
capture("04a-account")
|
||||||
|
XCTAssertTrue(any("account.save.profile").waitForExistence(timeout: 4), "Manca Salva profilo")
|
||||||
|
tapHittable(any("account.save.profile"))
|
||||||
|
sleep(2)
|
||||||
|
capture("04b-account-saved")
|
||||||
|
tapHittable(app.buttons["account.back"])
|
||||||
|
XCTAssertTrue(any("matches.new").waitForExistence(timeout: 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleMatchShowsConfigureDialog() {
|
||||||
|
tapNewMatch()
|
||||||
|
capture("05a-nuova-partita")
|
||||||
|
let schedule = firstExisting(["sheet.schedule.option"], labels: ["Programma partita"])
|
||||||
|
XCTAssertTrue(schedule.waitForExistence(timeout: 8), "Opzione Programma partita assente")
|
||||||
|
tapHittable(schedule)
|
||||||
|
let opponent = app.textFields["Avversario"].exists ? app.textFields["Avversario"] : app.textFields.firstMatch
|
||||||
|
XCTAssertTrue(opponent.waitForExistence(timeout: 8), "Campo avversario assente")
|
||||||
|
tapHittable(opponent)
|
||||||
|
opponent.typeText("UI Test Programmata")
|
||||||
|
dismissKeyboard()
|
||||||
|
let save = app.buttons["SALVA IN PROGRAMMA"]
|
||||||
|
XCTAssertTrue(save.waitForExistence(timeout: 4))
|
||||||
|
tapHittable(save)
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["Configurare ora?"].waitForExistence(timeout: 12) || app.alerts.firstMatch.waitForExistence(timeout: 4),
|
||||||
|
"Manca il dialog Configura/Più tardi"
|
||||||
|
)
|
||||||
|
capture("05b-dialog-configura")
|
||||||
|
let later = app.buttons["Più tardi"].exists ? app.buttons["Più tardi"] : app.alerts.buttons.element(boundBy: 0)
|
||||||
|
tapHittable(later)
|
||||||
|
XCTAssertTrue(any("matches.new").waitForExistence(timeout: 8))
|
||||||
|
XCTAssertTrue(
|
||||||
|
app.staticTexts["UI Test Programmata"].waitForExistence(timeout: 8)
|
||||||
|
|| app.staticTexts.matching(NSPredicate(format: "label CONTAINS 'Programmata'")).firstMatch.exists,
|
||||||
|
"La partita programmata non compare in hub"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapNewMatch() {
|
||||||
|
let neu = firstExisting(["matches.new"], labels: ["NUOVA PARTITA", "NEW MATCH"])
|
||||||
|
XCTAssertTrue(neu.waitForExistence(timeout: 8))
|
||||||
|
tapHittable(neu)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scrollToId(_ id: String) {
|
||||||
|
let el = any(id)
|
||||||
|
for _ in 0..<8 {
|
||||||
|
if el.exists && el.isHittable { return }
|
||||||
|
app.swipeUp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func any(_ id: String) -> XCUIElement {
|
||||||
|
app.descendants(matching: .any)[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func firstExisting(_ ids: [String], labels: [String] = []) -> XCUIElement {
|
||||||
|
for id in ids {
|
||||||
|
let el = any(id)
|
||||||
|
if el.exists { return el }
|
||||||
|
}
|
||||||
|
for label in labels {
|
||||||
|
let button = app.buttons[label]
|
||||||
|
if button.exists { return button }
|
||||||
|
let text = app.staticTexts[label]
|
||||||
|
if text.exists { return text }
|
||||||
|
}
|
||||||
|
if let firstId = ids.first {
|
||||||
|
return any(firstId)
|
||||||
|
}
|
||||||
|
return app.buttons[labels.first ?? ""]
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tapHittable(_ element: XCUIElement) {
|
||||||
|
if element.isHittable {
|
||||||
|
element.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissSystemDialogs() {
|
||||||
|
let spring = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
||||||
|
_ = spring.buttons["Non ora"].waitForExistence(timeout: 3)
|
||||||
|
|| spring.buttons["Not Now"].waitForExistence(timeout: 1)
|
||||||
|
|| app.alerts.buttons["Non ora"].waitForExistence(timeout: 1)
|
||||||
|
let labels = ["Non ora", "Not Now", "Not now", "OK", "Consenti", "Allow"]
|
||||||
|
for _ in 0..<8 {
|
||||||
|
var tapped = false
|
||||||
|
for label in labels {
|
||||||
|
if spring.buttons[label].exists {
|
||||||
|
spring.buttons[label].tap()
|
||||||
|
tapped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if app.alerts.buttons[label].exists {
|
||||||
|
app.alerts.buttons[label].tap()
|
||||||
|
tapped = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !tapped { break }
|
||||||
|
RunLoop.current.run(until: Date().addingTimeInterval(0.4))
|
||||||
|
}
|
||||||
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.08)).tap()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dismissKeyboard() {
|
||||||
|
if !app.keyboards.firstMatch.exists { return }
|
||||||
|
for name in ["Fine", "Done", "Return", "Invio"] {
|
||||||
|
let key = app.keyboards.buttons[name]
|
||||||
|
if key.exists {
|
||||||
|
key.tap()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
app.swipeDown()
|
||||||
|
sleep(1)
|
||||||
|
if app.keyboards.firstMatch.exists {
|
||||||
|
app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.12)).tap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func capture(_ name: String) {
|
||||||
|
let shot = app.screenshot()
|
||||||
|
try? shot.pngRepresentation.write(to: URL(fileURLWithPath: "\(shotDir)/\(name).png"))
|
||||||
|
let attachment = XCTAttachment(screenshot: shot)
|
||||||
|
attachment.name = name
|
||||||
|
attachment.lifetime = .keepAlways
|
||||||
|
add(attachment)
|
||||||
|
try? app.debugDescription.write(toFile: "\(shotDir)/\(name).txt", atomically: true, encoding: .utf8)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -96,7 +96,7 @@ lines += [
|
|||||||
app_settings = """
|
app_settings = """
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 32;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
GENERATE_INFOPLIST_FILE = NO;
|
GENERATE_INFOPLIST_FILE = NO;
|
||||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
@@ -107,7 +107,7 @@ app_settings = """
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
API_BASE_URL = "https://www.matchlivetv.it";
|
API_BASE_URL = "https://www.matchlivetv.it";
|
||||||
@@ -122,10 +122,10 @@ app_debug_settings = app_settings + """
|
|||||||
test_settings = """
|
test_settings = """
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 32;
|
CURRENT_PROJECT_VERSION = 35;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||||
MARKETING_VERSION = 2.0.10;
|
MARKETING_VERSION = 2.0.12;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv.tests;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
|
|||||||
Reference in New Issue
Block a user