Compare commits

..
Author SHA1 Message Date
eminuxandCursor 2db6541c91 Fix 500 in cancellazione partita con recording collegato.
Distruggendo la sessione ora si elimina anche il recording, evitando la violazione FK su recordings.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-11 08:09:34 +02:00
eminuxandCursor 5d4bd8544d Aggiunge le cover slate custom generate in ambiente locale.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:21:48 +02:00
eminuxandCursor cc95fcacbf Permette all’admin di forzare anagrafica, fiscali, owner, staff e inviti.
Ops può correggere dati clienti errati (es. email titolare) dalla scheda società senza interventi manuali sul DB.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 18:18:18 +02:00
eminuxandCursor d7dd744c32 Mostra in admin anagrafica e dati fiscali delle società.
Così Ops può emettere fattura dalla scheda club senza cercare il profilo solo tra i pagamenti pending.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 16:17:20 +02:00
eminuxandCursor adfea01cdb Permette di filtrare il grafico analytics su una singola pagina.
Aggiunge il filtro Pagina (grafico) e il link Grafico in tabella, mantenendo Da/A/Device.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:14:35 +02:00
eminuxandCursor 81e7e3c9af Semplifica il grafico analytics: solo pageview e toggle nascondi.
Chiarisce che il totale è su tutte le pagine (filtri Da/A/Device), distinto dal dettaglio per path.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 22:07:34 +02:00
Emiliano FrascaroandCursor 53ad26fdbe Alza iOS a build 38 per il rilascio da produzione.
La 37 era stata caricata da collaudo: App Store Connect richiede un numero di build nuovo dal branch di produzione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:58:09 +02:00
Emiliano FrascaroandCursor db0e842ca7 Prepara iOS 2.0.13 build 37 per lo Store con icona chiara e test del calendario.
Apple ha chiuso il treno 2.0.12: servono versione nuova, monogramma visibile sul nero e copertura dei casi dopo l'orario previsto.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:57:56 +02:00
eminuxandCursor 2e67e590d6 Aggiunge grafico andamento visite in admin Analytics.
Serie giornaliera di pageview/click/movimenti nel periodo filtrato, sopra la tabella pagine.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 21:52:29 +02:00
eminuxandCursor bb342ead79 Bump Android a 2.0.13-native (versionCode 34) per Play Store.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 08:48:08 +02:00
eminuxandCursor ce81bb5789 Nasconde dall'hub le partite con risultato già inserito.
Restano avviabili solo le gare in ritardo senza punteggio, non quelle già terminate dal portale.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:13:10 +02:00
eminuxandCursor 24c6ce4da0 Lascia trasmissibili le partite anche dopo l'orario previsto.
Se l'operatore è in ritardo deve ancora trovare la gara nell'app: restano in hub per tutta la giornata e, nei tornei aperti, fino alla fine dell'evento.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 10:07:23 +02:00
eminuxandCursor c3ef8e91a6 Rende usabili calendario, overlay ospite e stato diretta del cartellone.
Il calendario non deve più scorrere in orizzontale, il logo a destra non copre il nome e una diretta YouTube o in collegamento compare come In diretta.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:55:08 +02:00
eminuxandCursor b09e83db09 Allinea stato e titolo replay del tabellone al risultato reale.
Una gara con punteggio non resta più «In programma» solo perché l'orario è futuro, e i replay usano le squadre del match invece della squadra di trasmissione.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 09:15:10 +02:00
eminux 097e55df79 Merge branch 'feature/tornei' into collaudo 2026-09-05 15:06:24 +02:00
eminuxandCursor 7c7b2bf14c Alza soft capacity fase A e aggiunge quiet hours CPX notturne.
Home a 4 e cloud a 6 con MAX_NODES=12 (~76 soft); di notte (02–07 Europe/Rome) niente scale-out/warm-spare e sweeper che chiude i CPX idle.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 09:48:50 +02:00
90 changed files with 2099 additions and 172 deletions
@@ -6,7 +6,8 @@ module Admin
@filters = { @filters = {
from: parse_date(params[:from]) || 7.days.ago.to_date, from: parse_date(params[:from]) || 7.days.ago.to_date,
to: parse_date(params[:to]) || Time.zone.today, to: parse_date(params[:to]) || Time.zone.today,
device: params[:device].presence device: params[:device].presence,
chart_path: params[:chart_path].presence
} }
@analytics_preview_active = analytics_preview_active? @analytics_preview_active = analytics_preview_active?
@@ -36,6 +37,24 @@ module Admin
max_scroll: max_scroll_by_path[path].to_i max_scroll: max_scroll_by_path[path].to_i
} }
end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] } end.sort_by { |r| [-r[:pageviews], -r[:moves], -r[:clicks], r[:page_path]] }
@chart_path_options = @pages.map { |row| row[:page_path] }
if @filters[:chart_path].present? && @chart_path_options.exclude?(@filters[:chart_path])
@filters[:chart_path] = nil
end
trend_scope = scope
trend_scope = trend_scope.where(page_path: @filters[:chart_path]) if @filters[:chart_path].present?
pageviews_by_day = trend_scope.group(:day).sum(:pageview_count)
@trend = (@filters[:from]..@filters[:to]).map do |day|
{
day: day.iso8601,
pageviews: pageviews_by_day[day].to_i
}
end
@trend_totals = {
pageviews: @trend.sum { |r| r[:pageviews] }
}
end end
def show def show
@@ -1,18 +1,34 @@
module Admin module Admin
class ClubsController < BaseController class ClubsController < BaseController
before_action :set_club, only: %i[show grant_comped revoke_comped set_quote revoke_quote] before_action :set_club, only: %i[show edit update grant_comped revoke_comped set_quote revoke_quote]
def index def index
@clubs = Club.includes(:teams, :billing_quote, subscription: %i[plan admin_comped_by]) @clubs = Club.includes(:teams, :billing_quote, { club_memberships: :user }, subscription: %i[plan admin_comped_by])
.order(:name) .order(:name)
end end
def show def show
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active") load_show_context
@plans = Plan.ordered.reject { |p| p.slug == "free" } end
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote def edit
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20) load_edit_context
end
def update
Admin::UpdateClubData.call(
club: @club,
club_attrs: club_update_params,
owner_attrs: owner_params,
staff_attrs: staff_params,
invitation_attrs: invitation_params,
team_attrs: team_params
)
redirect_to admin_club_path(@club), notice: t("admin.flash.club_updated", club: @club.name)
rescue Admin::UpdateClubData::Error, ActiveRecord::RecordInvalid => e
flash.now[:alert] = e.message
load_edit_context
render :edit, status: :unprocessable_entity
end end
def grant_comped def grant_comped
@@ -63,7 +79,76 @@ module Admin
private private
def set_club def set_club
@club = Club.find(params[:id]) @club = Club.includes(club_memberships: :user).find(params[:id])
end
def load_show_context
@subscription = @club.subscription || @club.build_subscription(plan: Plan["free"], status: "active")
@plans = Plan.ordered.reject { |p| p.slug == "free" }
@teams = @club.teams.order(:name)
@quote = @club.active_billing_quote
@concurrency_violations = StreamConcurrencyViolation.for_club(@club.id).recent.limit(20)
end
def load_edit_context
@teams = @club.teams.includes(:user_teams, :team_invitations).order(:name)
@owner = @club.owner
@staff_users = User.joins(:user_teams)
.where(user_teams: { team_id: @club.teams.select(:id) })
.distinct
.order(:email)
.to_a
@pending_invitations = TeamInvitation.pending
.where(team_id: @club.teams.select(:id))
.includes(:team)
.order(:email)
@sport_options = Sports::Catalog.as_api_list.map { |entry| [entry[:label], entry[:key]] }
end
def club_update_params
params.require(:club).permit(
:name, :sport, :logo_url, :primary_color, :secondary_color,
:billing_entity_type, :billing_legal_name, :billing_vat_number, :billing_fiscal_code,
:billing_email, :billing_phone, :billing_address_line, :billing_city, :billing_province,
:billing_postal_code, :billing_country, :billing_recipient_code, :billing_pec
)
end
def owner_params
params.fetch(:owner, {}).permit(:name, :email).to_h.symbolize_keys
end
def staff_params
raw = params[:staff_users]
return {} if raw.blank?
raw.permit!.to_h.each_with_object({}) do |(user_id, attrs), acc|
next unless attrs.is_a?(Hash)
acc[user_id] = attrs.slice("name", "email").symbolize_keys
end
end
def invitation_params
raw = params[:invitations]
return {} if raw.blank?
raw.permit!.to_h.each_with_object({}) do |(invitation_id, attrs), acc|
next unless attrs.is_a?(Hash)
acc[invitation_id] = attrs.slice("email").symbolize_keys
end
end
def team_params
raw = params[:teams]
return {} if raw.blank?
raw.permit!.to_h.each_with_object({}) do |(team_id, attrs), acc|
next unless attrs.is_a?(Hash)
acc[team_id] = attrs.slice("name", "sport").symbolize_keys
end
end end
def redirect_back_or_club(notice: nil, alert: nil) def redirect_back_or_club(notice: nil, alert: nil)
@@ -10,7 +10,7 @@ module Api
def index def index
matches = @team.matches matches = @team.matches
.includes(:team, :stream_sessions, :home_participant, :away_participant) .includes(:team, :stream_sessions, :home_participant, :away_participant, :tournament)
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
unless current_user.club_admin?(@team.club) unless current_user.club_admin?(@team.club)
if @team.tournament_broadcast? if @team.tournament_broadcast?
@@ -16,7 +16,7 @@ module Public
Tournaments::Entitlements.new(@club).assert_writable! Tournaments::Entitlements.new(@club).assert_writable!
match = @tournament.matches.find(params[:match_id]) match = @tournament.matches.find(params[:match_id])
attrs = match_params attrs = match_params
if match.played? || match.result_status.to_s.start_with?("walkover") if match.result_recorded?
attrs = attrs.except(:home_participant_id, :away_participant_id) attrs = attrs.except(:home_participant_id, :away_participant_id)
end end
match.update!(attrs) match.update!(attrs)
@@ -38,19 +38,21 @@ module Public
.includes( .includes(
:tournament_group, :tournament_group,
:tournament_round, :tournament_round,
:stream_sessions,
home_participant: { logo_file_attachment: :blob }, home_participant: { logo_file_attachment: :blob },
away_participant: { logo_file_attachment: :blob } away_participant: { logo_file_attachment: :blob }
) )
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc) .order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
@live_sessions = StreamSession broadcasting = StreamSession
.broadcasting .broadcasting
.publicly_listed
.where(platform: "matchlivetv")
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }]) .includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
.where(match_id: @tournament.matches.select(:id)) .where(match_id: @tournament.matches.select(:id))
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc) .order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
@live_by_match_id = @live_sessions.index_by(&:match_id) .to_a
@live_by_match_id = {}
broadcasting.each { |session| @live_by_match_id[session.match_id] ||= session }
@live_sessions = broadcasting.select(&:public_watchable?)
@recordings = Recording.ready.publicly_listed @recordings = Recording.ready.publicly_listed
.joins(stream_session: :match) .joins(stream_session: :match)
@@ -67,7 +67,7 @@ module Public
sessions = match.stream_sessions.to_a sessions = match.stream_sessions.to_a
latest = sessions.max_by(&:created_at) latest = sessions.max_by(&:created_at)
if latest && !latest.status.in?(%w[ended error]) if latest && !latest.status.in?(%w[ended error])
return :live if latest.status.in?(%w[live reconnecting paused]) return :live if latest.status.in?(%w[live reconnecting paused connecting])
return :waiting_operator return :waiting_operator
end end
@@ -100,12 +100,24 @@ module Public
def tournament_public_board_state(match) def tournament_public_board_state(match)
return :live if tournament_public_live_session(match) return :live if tournament_public_live_session(match)
return :replay if tournament_public_recording(match) return :replay if tournament_public_recording(match)
return :ended if match.result_recorded?
return :scheduled if match.scheduled_upcoming? return :scheduled if match.scheduled_upcoming?
return :ended if match.played? || match.home_score.present?
:waiting :waiting
end end
def tournament_public_watch_target(session)
return unless session
if session.matchlivetv_platform? && session.publicly_listed?
return [public_live_path(session), t("tournaments.page.watch_live"), {}]
end
if session.youtube_watch_url.present?
return [session.youtube_watch_url, t("live.show.youtube_watch_link"), { target: "_blank", rel: "noopener" }]
end
nil
end
def tournament_public_score_label(match) def tournament_public_score_label(match)
return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present? return "#{match.home_score}#{match.away_score}" if match.home_score.present? && match.away_score.present?
@@ -117,7 +129,7 @@ module Public
end end
def tournament_match_sides_locked?(match) def tournament_match_sides_locked?(match)
match.played? || match.result_status.to_s.start_with?("walkover") match.result_recorded?
end end
def tournament_phase_label(match) def tournament_phase_label(match)
+8 -1
View File
@@ -21,7 +21,14 @@ class Club < ApplicationRecord
validates :secondary_color, presence: true validates :secondary_color, presence: true
def owner def owner
club_memberships.find_by(role: "owner")&.user memberships = club_memberships
membership =
if memberships.loaded?
memberships.detect { |m| m.role == "owner" }
else
memberships.find_by(role: "owner")
end
membership&.user
end end
def owned_by?(user) def owned_by?(user)
@@ -59,18 +59,43 @@ module ClubBillingProfile
def billing_profile_invoice_lines def billing_profile_invoice_lines
lines = [] lines = []
lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present? lines << ["Tipo", self.class.billing_entity_types[billing_entity_type]] if billing_entity_type.present?
lines << ["Intestatario", billing_legal_name] lines << ["Intestatario", billing_legal_name] if billing_legal_name.present?
lines << ["P.IVA", billing_vat_number] if billing_vat_number.present? lines << ["P.IVA", billing_vat_number] if billing_vat_number.present?
lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present? lines << ["Codice fiscale", billing_fiscal_code] if billing_fiscal_code.present?
lines << ["Email fatturazione", billing_email] lines << ["Email fatturazione", billing_email] if billing_email.present?
lines << ["Telefono", billing_phone] if billing_phone.present? lines << ["Telefono", billing_phone] if billing_phone.present?
addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ") core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank
lines << ["Indirizzo", addr] if addr.present? if core_address.any?
core_address << billing_country if billing_country.present?
lines << ["Indirizzo", core_address.join(", ")]
end
lines << ["SDI", billing_recipient_code] if billing_recipient_code.present? lines << ["SDI", billing_recipient_code] if billing_recipient_code.present?
lines << ["PEC", billing_pec] if billing_pec.present? lines << ["PEC", billing_pec] if billing_pec.present?
lines lines
end end
# Stato profilo fiscale per badge admin: :complete | :incomplete | :absent
def billing_profile_admin_status
return :complete if billing_profile_complete?
return :absent unless billing_profile_started?
:incomplete
end
def billing_profile_started?
billing_legal_name.present? ||
billing_vat_number.present? ||
billing_fiscal_code.present? ||
billing_email.present? ||
billing_phone.present? ||
billing_address_line.present? ||
billing_city.present? ||
billing_postal_code.present? ||
billing_province.present? ||
billing_recipient_code.present? ||
billing_pec.present?
end
private private
def billing_profile_for_invoicing def billing_profile_for_invoicing
+15 -2
View File
@@ -94,14 +94,27 @@ class Match < ApplicationRecord
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
end end
def result_recorded?
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
end
def coach_hub_visible? def coach_hub_visible?
active = active_stream_session active = active_stream_session
return true if active&.resumable? return true if active&.resumable?
return true if active&.idle? return true if active&.idle?
return false if result_recorded?
return false if stream_completed? return false if stream_completed?
return true if scheduled_on_calendar?
return true if tournament_open_for_late_stream?
scheduled_upcoming? false
end
def tournament_open_for_late_stream?
return false unless tournament_match?
event = tournament
event.present? && !event.archived? && event.ends_on >= Date.current
end end
def effective_board_type def effective_board_type
+22 -2
View File
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
end end
def title_or_default def title_or_default
title.presence || default_title match = stream_session&.match
return title.presence || "Replay" unless match
generated = match.matchup_label
stored = title.to_s.strip
return generated if stored.blank?
return generated if stale_auto_title?(match, stored)
stored
end end
def default_title def default_title
match = stream_session&.match match = stream_session&.match
return "Replay" unless match return "Replay" unless match
"#{match.team.name} vs #{match.opponent_name}" match.matchup_label
end end
def recorded_at_or_fallback def recorded_at_or_fallback
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
private private
def stale_auto_title?(match, stored)
return false unless match.tournament_match?
team_name = match.team&.name.to_s
return false if team_name.blank?
[
"#{team_name} vs #{match.opponent_name}",
"#{team_name} vs #{match.away_display_name}"
].include?(stored)
end
def normalize_privacy_status def normalize_privacy_status
self.privacy_status = "public" if privacy_status == "private" self.privacy_status = "public" if privacy_status == "private"
self.privacy_status = "unlisted" if privacy_status.blank? self.privacy_status = "unlisted" if privacy_status.blank?
+7 -1
View File
@@ -15,7 +15,7 @@ class StreamSession < ApplicationRecord
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation", has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
has_one :score_state, dependent: :destroy has_one :score_state, dependent: :destroy
has_one :recording has_one :recording, dependent: :destroy
has_many :device_states, dependent: :destroy has_many :device_states, dependent: :destroy
validates :platform, inclusion: { in: PLATFORMS } validates :platform, inclusion: { in: PLATFORMS }
@@ -186,6 +186,12 @@ class StreamSession < ApplicationRecord
platform == "youtube" && youtube_watch_url.present? platform == "youtube" && youtube_watch_url.present?
end end
def public_watchable?
return true if matchlivetv_platform? && publicly_listed?
youtube_ready?
end
def link_only? def link_only?
privacy_status.in?(%w[unlisted private]) privacy_status.in?(%w[unlisted private])
end end
@@ -0,0 +1,156 @@
# frozen_string_literal: true
module Admin
class UpdateClubData
class Error < StandardError; end
def self.call(**kwargs)
new(**kwargs).call
end
def initialize(club:, club_attrs:, owner_attrs: {}, staff_attrs: {}, invitation_attrs: {}, team_attrs: {})
@club = club
@club_attrs = club_attrs.to_h
@owner_attrs = owner_attrs.to_h
@staff_attrs = staff_attrs.to_h
@invitation_attrs = invitation_attrs.to_h
@team_attrs = team_attrs.to_h
end
def call
ActiveRecord::Base.transaction do
update_club!
update_owner!
update_staff!
update_invitations!
update_teams!
end
@club.reload
end
private
def update_club!
attrs = @club_attrs.dup
if attrs[:sport].present?
attrs[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
end
%i[primary_color secondary_color].each do |key|
next unless attrs.key?(key)
attrs[key] = normalize_hex(attrs[key], key == :primary_color ? "#e53935" : "#ffffff")
end
%i[
billing_legal_name billing_vat_number billing_fiscal_code billing_email billing_phone
billing_address_line billing_city billing_province billing_postal_code billing_country
billing_recipient_code billing_pec logo_url
].each do |key|
next unless attrs.key?(key)
attrs[key] = attrs[key].to_s.strip.presence
end
if attrs[:billing_province].present?
attrs[:billing_province] = attrs[:billing_province].to_s.upcase
end
if attrs[:billing_country].present?
attrs[:billing_country] = attrs[:billing_country].to_s.upcase
end
if attrs[:billing_recipient_code].present?
attrs[:billing_recipient_code] = attrs[:billing_recipient_code].to_s.upcase
end
@club.assign_attributes(attrs)
@club.save!
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
end
def update_owner!
return if @owner_attrs.blank?
owner = @club.owner
raise Error, I18n.t("admin.clubs.edit.errors.owner_missing") if owner.blank?
update_user!(owner, @owner_attrs)
end
def update_staff!
return if @staff_attrs.blank?
allowed_ids = staff_users.index_by(&:id)
@staff_attrs.each do |user_id, attrs|
user = allowed_ids[user_id.to_s] || allowed_ids[user_id]
next unless user
update_user!(user, attrs)
end
end
def update_invitations!
return if @invitation_attrs.blank?
pending = TeamInvitation.pending.where(team_id: @club.teams.select(:id)).index_by { |inv| inv.id.to_s }
@invitation_attrs.each do |invitation_id, attrs|
invitation = pending[invitation_id.to_s]
next unless invitation
email = attrs[:email].to_s.strip.presence
next if email.blank?
invitation.update!(email: email)
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
end
end
def update_teams!
return if @team_attrs.blank?
teams = @club.teams.index_by { |t| t.id.to_s }
@team_attrs.each do |team_id, attrs|
team = teams[team_id.to_s]
next unless team
updates = {}
updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
if attrs[:sport].present?
updates[:sport] = Sports::Catalog.normalize_key(attrs[:sport])
end
next if updates.empty?
team.update!(updates)
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
end
end
def update_user!(user, attrs)
updates = {}
updates[:name] = attrs[:name].to_s.strip if attrs.key?(:name) && attrs[:name].present?
if attrs.key?(:email)
email = attrs[:email].to_s.strip.presence
updates[:email] = email if email.present?
end
return if updates.empty?
user.update!(updates)
rescue ActiveRecord::RecordInvalid => e
raise Error, e.record.errors.full_messages.join(", ")
rescue ActiveRecord::RecordNotUnique
raise Error, I18n.t("admin.clubs.edit.errors.email_taken", email: updates[:email])
end
def staff_users
User.joins(:user_teams).where(user_teams: { team_id: @club.teams.select(:id) }).distinct.to_a
end
def normalize_hex(value, fallback)
raw = value.to_s.strip
return fallback if raw.blank?
return raw.downcase if raw.match?(/\A#[0-9a-fA-F]{6}\z/)
return "##{raw.downcase}" if raw.match?(/\A[0-9a-fA-F]{6}\z/)
fallback
end
end
end
@@ -73,7 +73,7 @@ module Recordings
def default_title def default_title
match = @session.match match = @session.match
"#{match.team.name} vs #{match.opponent_name}" match.matchup_label.presence || "Replay"
end end
def privacy_from_session def privacy_from_session
@@ -50,6 +50,14 @@
@filters[:device] @filters[:device]
) %> ) %>
</label> </label>
<label class="admin-filter-field">
<span><%= t("admin.analytics.filters.chart_path") %></span>
<%= select_tag :chart_path,
options_for_select(
[[t("admin.analytics.filters.chart_path_all"), ""]] + @chart_path_options.map { |p| [p, p] },
@filters[:chart_path]
) %>
</label>
</div> </div>
<div class="admin-filter-actions"> <div class="admin-filter-actions">
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %> <%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
@@ -58,8 +66,66 @@
<% end %> <% end %>
</div> </div>
<% if @trend.any? && @trend_totals[:pageviews].positive? %>
<section class="panel admin-analytics-trend" id="admin-analytics-trend">
<div class="admin-analytics-trend__head">
<div>
<h3><%= t("admin.analytics.index.trend_title") %></h3>
<p class="muted admin-table-sub">
<% if @filters[:chart_path].present? %>
<%= t("admin.analytics.index.trend_lead_path", path: @filters[:chart_path]) %>
<% else %>
<%= t("admin.analytics.index.trend_lead") %>
<% end %>
</p>
</div>
<button type="button"
id="admin-analytics-trend-toggle"
class="admin-btn admin-btn--outline admin-btn--sm"
aria-controls="admin-analytics-trend-body"
aria-expanded="true"><%= t("admin.analytics.index.hide_chart") %></button>
</div>
<div id="admin-analytics-trend-body">
<div class="kpi-grid admin-analytics-trend__kpi">
<div class="kpi">
<div class="kpi-label">
<% if @filters[:chart_path].present? %>
<%= t("admin.analytics.index.trend_total_label_path") %>
<% else %>
<%= t("admin.analytics.index.trend_total_label") %>
<% end %>
</div>
<div class="kpi-value"><%= @trend_totals[:pageviews] %></div>
<div class="kpi-sub muted">
<% if @filters[:chart_path].present? %>
<code class="admin-mono"><%= @filters[:chart_path] %></code>
·
<% end %>
<%= t("admin.analytics.index.trend_total_hint", from: l(@filters[:from]), to: l(@filters[:to])) %>
</div>
</div>
</div>
<div class="chart-wrap chart-wrap--analytics">
<canvas id="chart-analytics-trend" aria-label="<%= t("admin.analytics.index.trend_title") %>"></canvas>
</div>
</div>
</section>
<script>
window.adminAnalyticsTrend = <%= raw @trend.to_json %>;
window.adminAnalyticsI18n = {
pageviews: <%= raw t("admin.analytics.index.table.pageviews").to_json %>,
hideChart: <%= raw t("admin.analytics.index.hide_chart").to_json %>,
showChart: <%= raw t("admin.analytics.index.show_chart").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-analytics.js?v=3" defer></script>
<% end %>
<div class="panel"> <div class="panel">
<% if @pages.any? %> <% if @pages.any? %>
<h3 class="admin-analytics-pages-title"><%= t("admin.analytics.index.pages_title") %></h3>
<p class="muted admin-table-sub"><%= t("admin.analytics.index.pages_lead") %></p>
<div class="admin-table-wrap"> <div class="admin-table-wrap">
<table class="admin-table"> <table class="admin-table">
<thead> <thead>
@@ -76,14 +142,18 @@
<tbody> <tbody>
<% @pages.each do |row| %> <% @pages.each do |row| %>
<% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %> <% avg = row[:scroll_samples].positive? ? (row[:scroll_sum].to_f / row[:scroll_samples]).round : 0 %>
<tr> <% chart_params = { from: @filters[:from], to: @filters[:to], chart_path: row[:page_path] } %>
<% chart_params[:device] = @filters[:device] if @filters[:device].present? %>
<tr class="<%= "is-chart-focus" if @filters[:chart_path] == row[:page_path] %>">
<td><code class="admin-mono"><%= row[:page_path] %></code></td> <td><code class="admin-mono"><%= row[:page_path] %></code></td>
<td><%= row[:pageviews] %></td> <td><%= row[:pageviews] %></td>
<td><%= row[:moves] %></td> <td><%= row[:moves] %></td>
<td><%= row[:clicks] %></td> <td><%= row[:clicks] %></td>
<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 class="admin-analytics-row-actions">
<%= link_to t("admin.analytics.index.chart_for_path"), admin_analytics_path(chart_params) %>
·
<% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %> <% heatmap_params = { page_path: row[:page_path], from: @filters[:from], to: @filters[:to] } %>
<% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %> <% heatmap_params[:device] = @filters[:device] if @filters[:device].present? %>
<%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %> <%= link_to t("admin.analytics.index.heatmap"), admin_analytics_page_path(heatmap_params) %>
@@ -0,0 +1,46 @@
<% owner = club.owner %>
<% status = club.billing_profile_admin_status %>
<% lines = club.billing_profile_invoice_lines.select { |_label, value| value.present? } %>
<section class="panel admin-club-profile" style="margin:16px 0 24px">
<h3 style="font-size:1rem;margin:0 0 12px"><%= t("admin.clubs.show.profile_title") %></h3>
<h4 style="font-size:0.9rem;margin:0 0 8px;color:var(--muted)"><%= t("admin.clubs.show.owner_title") %></h4>
<% if owner %>
<dl class="billing-profile-dl" style="margin-bottom:16px">
<dt><%= t("admin.clubs.show.owner_name") %></dt>
<dd><%= owner.name %></dd>
<dt><%= t("admin.clubs.show.owner_email") %></dt>
<dd><%= mail_to owner.email %></dd>
</dl>
<% else %>
<p class="muted" style="margin:0 0 16px"><%= t("admin.clubs.show.owner_none") %></p>
<% end %>
<div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px">
<h4 style="font-size:0.9rem;margin:0;color:var(--muted)"><%= t("admin.clubs.show.billing_title") %></h4>
<span class="admin-billing-status admin-billing-status--<%= status %>">
<%= t("admin.clubs.billing_status.#{status}") %>
</span>
<%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(club), class: "admin-btn admin-btn--sm admin-btn--secondary" %>
</div>
<% if status == :absent || lines.empty? %>
<p class="muted" style="margin:0"><%= t("admin.clubs.show.billing_none") %></p>
<% else %>
<dl class="billing-profile-dl">
<% lines.each do |label, value| %>
<dt><%= label %></dt>
<dd><%= value %></dd>
<% end %>
</dl>
<% end %>
<% if status == :incomplete %>
<ul class="muted" style="margin:12px 0 0;padding-left:1.2rem;font-size:0.88rem">
<% club.billing_profile_errors.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
<% end %>
</section>
+139
View File
@@ -0,0 +1,139 @@
<p style="margin-bottom:16px">
<%= link_to t("admin.clubs.edit.back"), admin_club_path(@club) %>
</p>
<h2><%= t("admin.clubs.edit.title", club: @club.name) %></h2>
<p class="muted" style="margin-bottom:20px"><%= t("admin.clubs.edit.lead") %></p>
<% if flash.now[:alert].present? %>
<p class="admin-flash" style="background:#3d1b1b;border-color:#c62828"><%= flash.now[:alert] %></p>
<% end %>
<%= form_with url: admin_club_path(@club), method: :patch, local: true, class: "admin-form admin-form--wide" do %>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.club") %></h3>
<div class="admin-form-row">
<div>
<label for="club_name"><%= t("admin.clubs.edit.fields.name") %></label>
<input type="text" name="club[name]" id="club_name" value="<%= @club.name %>" required>
</div>
<div>
<label for="club_sport"><%= t("admin.clubs.edit.fields.sport") %></label>
<select name="club[sport]" id="club_sport" required>
<% @sport_options.each do |label, key| %>
<option value="<%= key %>" <%= "selected" if @club.sport == key %>><%= label %></option>
<% end %>
</select>
</div>
</div>
<div class="admin-form-row">
<div>
<label for="club_primary_color"><%= t("admin.clubs.edit.fields.primary_color") %></label>
<input type="text" name="club[primary_color]" id="club_primary_color" value="<%= @club.primary_color %>" maxlength="7">
</div>
<div>
<label for="club_secondary_color"><%= t("admin.clubs.edit.fields.secondary_color") %></label>
<input type="text" name="club[secondary_color]" id="club_secondary_color" value="<%= @club.secondary_color %>" maxlength="7">
</div>
</div>
<div>
<label for="club_logo_url"><%= t("admin.clubs.edit.fields.logo_url") %></label>
<input type="url" name="club[logo_url]" id="club_logo_url" value="<%= @club.logo_url %>">
</div>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.billing") %></h3>
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.billing_hint") %></p>
<%= render "shared/billing_profile_fields", record: @club, show_legend: false %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.owner") %></h3>
<% if @owner %>
<div class="admin-form-row">
<div>
<label for="owner_name"><%= t("admin.clubs.edit.fields.owner_name") %></label>
<input type="text" name="owner[name]" id="owner_name" value="<%= @owner.name %>" required>
</div>
<div>
<label for="owner_email"><%= t("admin.clubs.edit.fields.owner_email") %></label>
<input type="email" name="owner[email]" id="owner_email" value="<%= @owner.email %>" required>
</div>
</div>
<% else %>
<p class="muted"><%= t("admin.clubs.show.owner_none") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.staff") %></h3>
<% if @staff_users.any? %>
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.staff_hint") %></p>
<% @staff_users.each do |user| %>
<div class="admin-form-row" style="margin-bottom:0.75rem">
<div>
<label><%= t("admin.clubs.edit.fields.staff_name") %></label>
<input type="text" name="staff_users[<%= user.id %>][name]" value="<%= user.name %>">
</div>
<div>
<label><%= t("admin.clubs.edit.fields.staff_email") %></label>
<input type="email" name="staff_users[<%= user.id %>][email]" value="<%= user.email %>">
</div>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("admin.clubs.edit.staff_none") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.invitations") %></h3>
<% if @pending_invitations.any? %>
<p class="muted admin-form-hint"><%= t("admin.clubs.edit.invitations_hint") %></p>
<% @pending_invitations.each do |invitation| %>
<div class="admin-form-row" style="margin-bottom:0.75rem">
<div>
<label><%= t("admin.clubs.edit.fields.invitation_team") %></label>
<input type="text" value="<%= invitation.team.name %>" disabled>
</div>
<div>
<label><%= t("admin.clubs.edit.fields.invitation_email") %></label>
<input type="email" name="invitations[<%= invitation.id %>][email]" value="<%= invitation.email %>" required>
</div>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("admin.clubs.edit.invitations_none") %></p>
<% end %>
</section>
<section class="panel">
<h3><%= t("admin.clubs.edit.sections.teams") %></h3>
<% if @teams.any? %>
<% @teams.each do |team| %>
<div class="admin-form-row" style="margin-bottom:0.75rem">
<div>
<label><%= t("admin.clubs.edit.fields.team_name") %></label>
<input type="text" name="teams[<%= team.id %>][name]" value="<%= team.name %>" required>
</div>
<div>
<label><%= t("admin.clubs.edit.fields.team_sport") %></label>
<select name="teams[<%= team.id %>][sport]">
<% @sport_options.each do |label, key| %>
<option value="<%= key %>" <%= "selected" if team.sport == key %>><%= label %></option>
<% end %>
</select>
</div>
</div>
<% end %>
<% else %>
<p class="muted"><%= t("admin.clubs.show.no_teams") %></p>
<% end %>
</section>
<div class="admin-form-actions">
<%= link_to t("admin.clubs.edit.cancel"), admin_club_path(@club), class: "admin-btn admin-btn--secondary" %>
<button type="submit" class="admin-btn admin-btn--primary"><%= t("admin.clubs.edit.submit") %></button>
</div>
<% end %>
@@ -9,6 +9,7 @@
<th><%= t("admin.clubs.index.table.club") %></th> <th><%= t("admin.clubs.index.table.club") %></th>
<th><%= t("admin.clubs.index.table.plan") %></th> <th><%= t("admin.clubs.index.table.plan") %></th>
<th><%= t("admin.clubs.index.table.teams") %></th> <th><%= t("admin.clubs.index.table.teams") %></th>
<th><%= t("admin.clubs.index.table.billing_profile") %></th>
<th><%= t("admin.clubs.index.table.comped") %></th> <th><%= t("admin.clubs.index.table.comped") %></th>
<th><%= t("admin.clubs.index.table.stripe") %></th> <th><%= t("admin.clubs.index.table.stripe") %></th>
<th><%= t("admin.clubs.index.table.quote") %></th> <th><%= t("admin.clubs.index.table.quote") %></th>
@@ -18,10 +19,16 @@
<tbody> <tbody>
<% @clubs.each do |club| %> <% @clubs.each do |club| %>
<% sub = club.subscription %> <% sub = club.subscription %>
<% billing_status = club.billing_profile_admin_status %>
<tr> <tr>
<td><strong><%= club.name %></strong></td> <td><strong><%= club.name %></strong></td>
<td><%= sub&.plan&.name || t("admin.common.free_plan") %></td> <td><%= sub&.plan&.name || t("admin.common.free_plan") %></td>
<td><%= club.teams.size %></td> <td><%= club.teams.size %></td>
<td>
<span class="admin-billing-status admin-billing-status--<%= billing_status %>">
<%= t("admin.clubs.billing_status.#{billing_status}") %>
</span>
</td>
<td> <td>
<% if sub&.admin_comped? %> <% if sub&.admin_comped? %>
<span style="color:#ffb74d"><%= t("admin.common.yes") %></span> <span style="color:#ffb74d"><%= t("admin.common.yes") %></span>
@@ -6,8 +6,12 @@
· <%= link_to t("admin.clubs.show.recordings_archive"), admin_club_recordings_path(@club) %> · <%= link_to t("admin.clubs.show.recordings_archive"), admin_club_recordings_path(@club) %>
· <%= link_to t("admin.clubs.show.billing_link"), admin_billing_path(club_id: @club.id) %> · <%= link_to t("admin.clubs.show.billing_link"), admin_billing_path(club_id: @club.id) %>
· <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %> · <%= link_to t("admin.clubs.show.youtube_platform_link"), admin_youtube_platform_path %>
· <%= link_to t("admin.clubs.show.edit_link"), edit_admin_club_path(@club) %>
</p> </p>
<%= render "admin/clubs/billing_profile", club: @club %>
<%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %> <%= render "admin/clubs/comped_form", club: @club, subscription: @subscription, return_to: admin_club_path(@club) %>
<%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %> <%= render "admin/clubs/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<link rel="stylesheet" href="/admin.css?v=16"> <link rel="stylesheet" href="/admin.css?v=21">
<%= yield :head %> <%= 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">
+2 -2
View File
@@ -10,7 +10,7 @@
<%= render "shared/analytics_suppress" %> <%= 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=101"> <link rel="stylesheet" href="/marketing.css?v=102">
</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" %>
@@ -24,7 +24,7 @@
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %> <%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
<script src="/branding-form.js?v=1" defer></script> <script src="/branding-form.js?v=1" defer></script>
<script src="/tournament-invite-editor.js?v=2" defer></script> <script src="/tournament-invite-editor.js?v=2" defer></script>
<script src="/tournament-calendar.js?v=1" defer></script> <script src="/tournament-calendar.js?v=2" defer></script>
<script src="/roster-form.js?v=1" defer></script> <script src="/roster-form.js?v=1" defer></script>
<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">
@@ -8,7 +8,7 @@
<%= render "shared/meta_tags" %> <%= render "shared/meta_tags" %>
<%= render "shared/analytics_suppress" %> <%= 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=101"> <link rel="stylesheet" href="/marketing.css?v=102">
<link rel="stylesheet" href="/live.css?v=26"> <link rel="stylesheet" href="/live.css?v=26">
<%= yield :head %> <%= yield :head %>
</head> </head>
@@ -37,8 +37,12 @@
</span> </span>
</td> </td>
<td class="tournament-board__actions"> <td class="tournament-board__actions">
<% if live_session %> <% watch = tournament_public_watch_target(live_session) %>
<%= link_to t("tournaments.page.watch_live"), public_live_path(live_session), class: "tournament-board__cta tournament-board__cta--live" %> <% if watch %>
<% url, label, html_opts = watch %>
<%= link_to label, url, { class: "tournament-board__cta tournament-board__cta--live" }.merge(html_opts) %>
<% elsif live_session %>
<span class="muted"><%= t("tournaments.page.no_media") %></span>
<% elsif recording %> <% elsif recording %>
<%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %> <%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %>
<% else %> <% else %>
@@ -52,8 +52,11 @@
<span class="replay-card__play" aria-hidden="true"></span> <span class="replay-card__play" aria-hidden="true"></span>
</div> </div>
<div class="replay-card__body"> <div class="replay-card__body">
<strong class="replay-card__title"><%= rec.title.presence || match.matchup_label %></strong> <% phase = [match.tournament_round&.name, match.tournament_group&.name].compact.first %>
<p class="replay-card__meta"><%= match.matchup_label %></p> <strong class="replay-card__title"><%= match.matchup_label %></strong>
<p class="replay-card__meta">
<%= [phase, rec.recorded_at_or_fallback && l(rec.recorded_at_or_fallback, format: :short)].compact.join(" · ") %>
</p>
</div> </div>
<% end %> <% end %>
<% end %> <% end %>
@@ -64,7 +64,11 @@
<span class="badge badge-live"><%= t("live.index.badge_live") %></span> <span class="badge badge-live"><%= t("live.index.badge_live") %></span>
<% end %> <% end %>
</div> </div>
<%= link_to t("tournaments.page.watch_live"), public_live_path(session), class: "btn-watch" %> <% watch = tournament_public_watch_target(session) %>
<% if watch %>
<% url, label, html_opts = watch %>
<%= link_to label, url, { class: "btn-watch" }.merge(html_opts) %>
<% end %>
</article> </article>
<% end %> <% end %>
</div> </div>
@@ -8,40 +8,35 @@
<% if @matches.any? %> <% if @matches.any? %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p> <p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p>
<div class="tournament-table-wrap"> <div class="tournament-match-list tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>">
<table class="data tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>">
<thead>
<tr>
<th><%= t("tournaments.hub.datetime") %></th>
<th><%= t("tournaments.hub.court") %></th>
<th><%= t("tournaments.hub.round") %></th>
<th><%= t("tournaments.hub.home") %></th>
<th class="tournament-match-swap-col"></th>
<th><%= t("tournaments.hub.away") %></th>
<th><%= t("tournaments.hub.result") %></th>
<th></th>
</tr>
</thead>
<tbody>
<% @matches.each do |match| %> <% @matches.each do |match| %>
<% form_id = "match-edit-#{match.id}" %> <% form_id = "match-edit-#{match.id}" %>
<% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %> <% courts = (@tournament.court_list + [match.court]).compact_blank.uniq %>
<%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %> <%= form_with url: public_tournament_match_path(@tournament, match), method: :patch, html: { id: form_id, class: "visually-hidden" } do %>
<%= hidden_field_tag :tab, @tab, form: form_id %> <%= hidden_field_tag :tab, @tab, form: form_id %>
<% end %> <% end %>
<tr> <article class="tournament-match-row">
<% if @writable %> <% if @writable %>
<td> <div class="tournament-match-row__field">
<label for="match_at_<%= match.id %>"><%= t("tournaments.hub.datetime") %></label>
<%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at), <%= datetime_local_field_tag "match[scheduled_at]", tournament_datetime_local(match.scheduled_at),
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %> id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
</td> </div>
<td> <div class="tournament-match-row__field">
<label for="match_court_<%= match.id %>"><%= t("tournaments.hub.court") %></label>
<%= select_tag "match[court]", options_for_select(courts, match.court), <%= select_tag "match[court]", options_for_select(courts, match.court),
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %> id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
</td> </div>
<td><%= tournament_phase_label(match) %></td> <div class="tournament-match-row__field tournament-match-row__field--phase">
<td data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></td> <span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
<td class="tournament-match-swap-col"> <p class="tournament-match-row__phase"><%= tournament_phase_label(match) %></p>
</div>
<div class="tournament-match-row__sides">
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.home") %></span>
<div data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></div>
</div>
<div class="tournament-match-swap-col">
<%= button_to public_swap_tournament_match_path(@tournament, match), method: :post, <%= button_to public_swap_tournament_match_path(@tournament, match), method: :post,
class: "tournament-match-swap__btn", class: "tournament-match-swap__btn",
title: t("tournaments.hub.swap_sides"), title: t("tournaments.hub.swap_sides"),
@@ -49,11 +44,15 @@
<i class="fa-solid fa-right-left" aria-hidden="true"></i> <i class="fa-solid fa-right-left" aria-hidden="true"></i>
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span> <span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
<% end %> <% end %>
</td> </div>
<td data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></td> <div class="tournament-match-row__side">
<td> <span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<% if match.played? || match.result_status.to_s.start_with?("walkover") %> <div data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></div>
<span data-swap-played-score><%= match.home_score %><%= match.away_score %></span> </div>
<div class="tournament-match-row__score">
<span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
<% if match.result_recorded? %>
<p data-swap-played-score><%= match.home_score %><%= match.away_score %></p>
<% else %> <% else %>
<span class="tournament-match-form__score"> <span class="tournament-match-form__score">
<%= number_field_tag :home_score, match.home_score, id: "home_score_#{match.id}", form: form_id, data: { swap_score: "home" } %> <%= number_field_tag :home_score, match.home_score, id: "home_score_#{match.id}", form: form_id, data: { swap_score: "home" } %>
@@ -61,35 +60,53 @@
<%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %> <%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %>
</span> </span>
<% end %> <% end %>
</td> </div>
<td class="tournament-match-form__actions"> </div>
<div class="tournament-match-form__actions tournament-match-row__actions">
<%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %> <%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %>
<% if match.deletable? %> <% if match.deletable? %>
<%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match), <%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match),
method: :delete, class: "btn btn-secondary tournament-match-form__save", method: :delete, class: "btn btn-secondary tournament-match-form__save",
form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %> form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %>
<% end %> <% end %>
</td> </div>
<% else %> <% else %>
<td><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></td> <div class="tournament-match-row__field">
<td><%= match.court || "—" %></td> <span class="tournament-match-row__label"><%= t("tournaments.hub.datetime") %></span>
<td><%= tournament_phase_label(match) %></td> <p class="tournament-match-row__phase"><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></p>
<td><%= tournament_team_chip(match.home_participant, name: match.home_display_name) %></td> </div>
<td class="tournament-match-swap-col"></td> <div class="tournament-match-row__field">
<td><%= tournament_team_chip(match.away_participant, name: match.away_display_name) %></td> <span class="tournament-match-row__label"><%= t("tournaments.hub.court") %></span>
<td> <p class="tournament-match-row__phase"><%= match.court || "—" %></p>
</div>
<div class="tournament-match-row__field tournament-match-row__field--phase">
<span class="tournament-match-row__label"><%= t("tournaments.hub.round") %></span>
<p class="tournament-match-row__phase"><%= tournament_phase_label(match) %></p>
</div>
<div class="tournament-match-row__sides">
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.home") %></span>
<%= tournament_team_chip(match.home_participant, name: match.home_display_name) %>
</div>
<div class="tournament-match-swap-col" aria-hidden="true"></div>
<div class="tournament-match-row__side">
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
<%= tournament_team_chip(match.away_participant, name: match.away_display_name) %>
</div>
<div class="tournament-match-row__score">
<span class="tournament-match-row__label"><%= t("tournaments.hub.result") %></span>
<p>
<% if match.home_score.present? %> <% if match.home_score.present? %>
<%= match.home_score %><%= match.away_score %> <%= match.home_score %><%= match.away_score %>
<% else %> <% else %>
<% end %> <% end %>
</td> </p>
<td></td> </div>
</div>
<% end %> <% end %>
</tr> </article>
<% end %> <% end %>
</tbody>
</table>
</div> </div>
<% else %> <% else %>
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p> <p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p>
+63
View File
@@ -48,6 +48,7 @@ de:
invoice_issued: "Rechnung %{number} ausgestellt und an %{email} gesendet." invoice_issued: "Rechnung %{number} ausgestellt und an %{email} gesendet."
invoice_updated: "Rechnung %{number} aktualisiert." invoice_updated: "Rechnung %{number} aktualisiert."
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet." invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
club_updated: "Daten für %{club} aktualisiert."
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
announcement_created: Hinweis gespeichert announcement_created: Hinweis gespeichert
announcement_updated: Hinweis aktualisiert announcement_updated: Hinweis aktualisiert
@@ -239,8 +240,10 @@ de:
club: Verein club: Verein
plan: Plan plan: Plan
teams: Teams teams: Teams
billing_profile: Rechnungsdaten
comped: Kostenlos comped: Kostenlos
stripe: Stripe stripe: Stripe
quote: Vereinbart
manage: Verwalten manage: Verwalten
invoices: Rechnungen invoices: Rechnungen
show: show:
@@ -254,6 +257,13 @@ de:
youtube_premium_full_not_connected: Premium Full — Vereinskanal nicht verbunden (nutzt Match Live TV) youtube_premium_full_not_connected: Premium Full — Vereinskanal nicht verbunden (nutzt Match Live TV)
no_channel_hint_html: "Ohne verbundenen Kanal nutzt die App den Match-Live-TV-Kanal. %{link}." no_channel_hint_html: "Ohne verbundenen Kanal nutzt die App den Match-Live-TV-Kanal. %{link}."
link_channel: Kanal verbinden (Vereinsseite) link_channel: Kanal verbinden (Vereinsseite)
profile_title: Kontaktdaten & Rechnungsdaten
owner_title: Kontoinhaber
owner_name: Name
owner_email: E-Mail
owner_none: Kein Inhaber zugeordnet.
billing_title: Rechnungsdaten
billing_none: Keine Rechnungsdaten hinterlegt.
teams_title: Teams teams_title: Teams
no_teams: Keine Teams registriert. no_teams: Keine Teams registriert.
table: table:
@@ -265,6 +275,46 @@ de:
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief. concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief.
concurrency_none: Keine Versuche für diesen Verein erfasst. concurrency_none: Keine Versuche für diesen Verein erfasst.
concurrency_all: Alle Konto-Missbräuche ansehen concurrency_all: Alle Konto-Missbräuche ansehen
edit_link: Daten bearbeiten
edit:
back: "← Zurück zum Verein"
title: "%{club} bearbeiten"
lead: "Kundendaten überschreiben: Profil, Abrechnung, Inhaber, Staff, Einladungen und Teams."
cancel: Abbrechen
submit: Änderungen speichern
billing_hint: Du kannst ein unvollständiges Profil speichern; der Badge auf der Vereinsseite zeigt den Status.
staff_hint: Bereits mit den Teams des Vereins verknüpfte Benutzer.
staff_none: Kein Staff mit Teams verknüpft.
invitations_hint: Nur noch ausstehende Einladungen.
invitations_none: Keine ausstehenden Einladungen.
sections:
club: Vereinsprofil
billing: Rechnungsdaten
owner: Kontoinhaber
staff: Angenommenes Staff
invitations: Ausstehende Einladungen
teams: Teams
fields:
name: Vereinsname
sport: Sportart
primary_color: Primärfarbe
secondary_color: Sekundärfarbe
logo_url: Logo-URL
owner_name: Name des Inhabers
owner_email: E-Mail des Inhabers
staff_name: Name
staff_email: E-Mail
invitation_team: Team
invitation_email: Einladungs-E-Mail
team_name: Teamname
team_sport: Teamsport
errors:
owner_missing: Kein Inhaber mit diesem Verein verknüpft.
email_taken: "E-Mail bereits vergeben: %{email}"
billing_status:
complete: Vollständig
incomplete: Unvollständig
absent: Fehlend
comped: comped:
title: Kostenloses Abonnement title: Kostenloses Abonnement
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar." description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
@@ -415,6 +465,8 @@ de:
any: Alle any: Alle
apply: Filtern apply: Filtern
reset: Zurücksetzen reset: Zurücksetzen
chart_path: Seite (Diagramm)
chart_path_all: Alle Seiten
layer: Ebene layer: Ebene
layer_move: Mausbewegungen layer_move: Mausbewegungen
layer_click: Klicks layer_click: Klicks
@@ -427,6 +479,17 @@ de:
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.
none: Keine Daten im gewählten Zeitraum. none: Keine Daten im gewählten Zeitraum.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Diagramm
trend_title: Pageview-Verlauf
trend_lead: Gesamte Pageviews aller Seiten, Tag für Tag. Dieselben Filter Von / Bis / Gerät. Seite im Filter wählen oder «Diagramm» in der Tabelle.
trend_lead_path: "Tägliche Pageviews nur für %{path} (gleiche Filter Von / Bis / Gerät)."
trend_total_label: Pageviews gesamt (alle Seiten)
trend_total_label_path: Pageviews der gewählten Seite
trend_total_hint: "%{from} → %{to}"
hide_chart: Diagramm ausblenden
show_chart: Diagramm anzeigen
pages_title: Aufschlüsselung nach Seite
pages_lead: Pageviews und Engagement im gefilterten Zeitraum, nach Pfad. «Diagramm» zeigt den Verlauf einer Seite.
table: table:
path: Seite path: Seite
pageviews: Pageviews pageviews: Pageviews
+63
View File
@@ -48,6 +48,7 @@ en:
invoice_issued: "Invoice %{number} issued and sent to %{email}." invoice_issued: "Invoice %{number} issued and sent to %{email}."
invoice_updated: "Invoice %{number} updated." invoice_updated: "Invoice %{number} updated."
invoice_uploaded: "Invoice uploaded and sent to %{email}." invoice_uploaded: "Invoice uploaded and sent to %{email}."
club_updated: "Data updated for %{club}."
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
announcement_created: Notice saved announcement_created: Notice saved
announcement_updated: Notice updated announcement_updated: Notice updated
@@ -239,8 +240,10 @@ en:
club: Club club: Club
plan: Plan plan: Plan
teams: Teams teams: Teams
billing_profile: Billing data
comped: Comp comped: Comp
stripe: Stripe stripe: Stripe
quote: Quoted
manage: Manage manage: Manage
invoices: Invoices invoices: Invoices
show: show:
@@ -254,6 +257,13 @@ en:
youtube_premium_full_not_connected: Premium Full — club channel not connected (using Match Live TV) youtube_premium_full_not_connected: Premium Full — club channel not connected (using Match Live TV)
no_channel_hint_html: "Without a connected channel, the app uses the Match Live TV channel. %{link}." no_channel_hint_html: "Without a connected channel, the app uses the Match Live TV channel. %{link}."
link_channel: Connect channel (club page) link_channel: Connect channel (club page)
profile_title: Contact & billing details
owner_title: Account owner
owner_name: Name
owner_email: Email
owner_none: No owner associated.
billing_title: Billing details
billing_none: No billing details provided.
teams_title: Teams teams_title: Teams
no_teams: No teams registered. no_teams: No teams registered.
table: table:
@@ -265,6 +275,46 @@ en:
concurrency_lead: Same account tried to start another live while one was already running. concurrency_lead: Same account tried to start another live while one was already running.
concurrency_none: No attempts recorded for this club. concurrency_none: No attempts recorded for this club.
concurrency_all: View all account abuse concurrency_all: View all account abuse
edit_link: Edit data
edit:
back: "← Back to club"
title: "Edit %{club}"
lead: "Override client-entered data: profile, billing, owner, staff, invitations and teams."
cancel: Cancel
submit: Save changes
billing_hint: You can save an incomplete profile; the badge on the club page will reflect the status.
staff_hint: Users already linked to this club's teams (including owners if also staff).
staff_none: No staff linked to teams.
invitations_hint: Only invitations still awaiting acceptance.
invitations_none: No pending invitations.
sections:
club: Club profile
billing: Billing details
owner: Account owner
staff: Accepted staff
invitations: Pending invitations
teams: Teams
fields:
name: Club name
sport: Sport
primary_color: Primary colour
secondary_color: Secondary colour
logo_url: Logo URL
owner_name: Owner name
owner_email: Owner email
staff_name: Name
staff_email: Email
invitation_team: Team
invitation_email: Invitation email
team_name: Team name
team_sport: Team sport
errors:
owner_missing: No owner associated with this club.
email_taken: "Email already in use: %{email}"
billing_status:
complete: Complete
incomplete: Incomplete
absent: Missing
comped: comped:
title: Complimentary subscription title: Complimentary subscription
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time." description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
@@ -415,6 +465,8 @@ en:
any: All any: All
apply: Filter apply: Filter
reset: Reset reset: Reset
chart_path: Page (chart)
chart_path_all: All pages
layer: Layer layer: Layer
layer_move: Mouse moves layer_move: Mouse moves
layer_click: Clicks layer_click: Clicks
@@ -427,6 +479,17 @@ en:
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.
none: No data in the selected period. none: No data in the selected period.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Chart
trend_title: Pageview trend
trend_lead: Total pageviews across all pages, day by day. Uses the same From / To / Device filters. Pick a page in the filter or click «Chart» in the table.
trend_lead_path: "Day-by-day pageviews for %{path} only (same From / To / Device filters)."
trend_total_label: Total pageviews (all pages)
trend_total_label_path: Pageviews for selected page
trend_total_hint: "%{from} → %{to}"
hide_chart: Hide chart
show_chart: Show chart
pages_title: Breakdown by page
pages_lead: Pageviews and engagement for the filtered period, split by path. Use «Chart» to see one page over time.
table: table:
path: Page path: Page
pageviews: Pageviews pageviews: Pageviews
+63
View File
@@ -48,6 +48,7 @@ es:
invoice_issued: "Factura %{number} emitida y enviada a %{email}." invoice_issued: "Factura %{number} emitida y enviada a %{email}."
invoice_updated: "Factura %{number} actualizada." invoice_updated: "Factura %{number} actualizada."
invoice_uploaded: "Factura subida y enviada a %{email}." invoice_uploaded: "Factura subida y enviada a %{email}."
club_updated: "Datos actualizados para %{club}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
announcement_created: Aviso guardado announcement_created: Aviso guardado
announcement_updated: Aviso actualizado announcement_updated: Aviso actualizado
@@ -239,8 +240,10 @@ es:
club: Club club: Club
plan: Plan plan: Plan
teams: Equipos teams: Equipos
billing_profile: Datos fiscales
comped: Cortesía comped: Cortesía
stripe: Stripe stripe: Stripe
quote: Acordado
manage: Gestionar manage: Gestionar
invoices: Facturas invoices: Facturas
show: show:
@@ -254,6 +257,13 @@ es:
youtube_premium_full_not_connected: Premium Full — canal del club no conectado (usa Match Live TV) youtube_premium_full_not_connected: Premium Full — canal del club no conectado (usa Match Live TV)
no_channel_hint_html: "Sin un canal conectado, la app usa el canal de Match Live TV. %{link}." no_channel_hint_html: "Sin un canal conectado, la app usa el canal de Match Live TV. %{link}."
link_channel: Conectar canal (página del club) link_channel: Conectar canal (página del club)
profile_title: Contacto y datos fiscales
owner_title: Titular de la cuenta
owner_name: Nombre
owner_email: Correo
owner_none: No hay titular asociado.
billing_title: Datos de facturación
billing_none: No hay datos de facturación.
teams_title: Equipos teams_title: Equipos
no_teams: No hay equipos registrados. no_teams: No hay equipos registrados.
table: table:
@@ -265,6 +275,46 @@ es:
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso. concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso.
concurrency_none: No hay intentos registrados para este club. concurrency_none: No hay intentos registrados para este club.
concurrency_all: Ver todos los abusos de cuenta concurrency_all: Ver todos los abusos de cuenta
edit_link: Editar datos
edit:
back: "← Volver al club"
title: "Editar %{club}"
lead: "Fuerza los datos introducidos por el cliente: perfil, facturación, titular, staff, invitaciones y equipos."
cancel: Cancelar
submit: Guardar cambios
billing_hint: Puedes guardar un perfil incompleto; el badge de la ficha reflejará el estado.
staff_hint: Usuarios ya vinculados a los equipos del club.
staff_none: No hay staff vinculado a los equipos.
invitations_hint: Solo invitaciones pendientes de aceptación.
invitations_none: No hay invitaciones pendientes.
sections:
club: Perfil del club
billing: Datos de facturación
owner: Titular de la cuenta
staff: Staff aceptado
invitations: Invitaciones pendientes
teams: Equipos
fields:
name: Nombre del club
sport: Deporte
primary_color: Color primario
secondary_color: Color secundario
logo_url: URL del logo
owner_name: Nombre del titular
owner_email: Correo del titular
staff_name: Nombre
staff_email: Correo
invitation_team: Equipo
invitation_email: Correo de invitación
team_name: Nombre del equipo
team_sport: Deporte del equipo
errors:
owner_missing: No hay titular asociado a este club.
email_taken: "Correo ya en uso: %{email}"
billing_status:
complete: Completo
incomplete: Incompleto
absent: Ausentes
comped: comped:
title: Suscripción de cortesía title: Suscripción de cortesía
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento." description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
@@ -415,6 +465,8 @@ es:
any: Todos any: Todos
apply: Filtrar apply: Filtrar
reset: Restablecer reset: Restablecer
chart_path: Página (gráfico)
chart_path_all: Todas las páginas
layer: Capa layer: Capa
layer_move: Movimientos del ratón layer_move: Movimientos del ratón
layer_click: Clics layer_click: Clics
@@ -427,6 +479,17 @@ es:
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.
none: No hay datos en el periodo seleccionado. none: No hay datos en el periodo seleccionado.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Gráfico
trend_title: Evolución de pageviews
trend_lead: Total de pageviews de todas las páginas, día a día. Usa los mismos filtros Desde / Hasta / Device. Elige una página en el filtro o pulsa «Gráfico» en la tabla.
trend_lead_path: "Pageviews día a día solo para %{path} (mismos filtros Desde / Hasta / Device)."
trend_total_label: Pageviews totales (todas las páginas)
trend_total_label_path: Pageviews de la página seleccionada
trend_total_hint: "%{from} → %{to}"
hide_chart: Ocultar gráfico
show_chart: Mostrar gráfico
pages_title: Detalle por página
pages_lead: Pageviews y engagement en el periodo filtrado, por path. Usa «Gráfico» para ver una sola página en el tiempo.
table: table:
path: Página path: Página
pageviews: Pageviews pageviews: Pageviews
+63
View File
@@ -48,6 +48,7 @@ fr:
invoice_issued: "Facture %{number} émise et envoyée à %{email}." invoice_issued: "Facture %{number} émise et envoyée à %{email}."
invoice_updated: "Facture %{number} mise à jour." invoice_updated: "Facture %{number} mise à jour."
invoice_uploaded: "Facture chargée et envoyée à %{email}." invoice_uploaded: "Facture chargée et envoyée à %{email}."
club_updated: "Données mises à jour pour %{club}."
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
announcement_created: Alerte enregistrée announcement_created: Alerte enregistrée
announcement_updated: Alerte mise à jour announcement_updated: Alerte mise à jour
@@ -239,8 +240,10 @@ fr:
club: Club club: Club
plan: Forfait plan: Forfait
teams: Équipes teams: Équipes
billing_profile: Données fiscales
comped: Offert comped: Offert
stripe: Stripe stripe: Stripe
quote: Convenu
manage: Gérer manage: Gérer
invoices: Factures invoices: Factures
show: show:
@@ -254,6 +257,13 @@ fr:
youtube_premium_full_not_connected: Premium Full — chaîne du club non connectée (utilise Match Live TV) youtube_premium_full_not_connected: Premium Full — chaîne du club non connectée (utilise Match Live TV)
no_channel_hint_html: "Sans chaîne connectée, l'application utilise la chaîne Match Live TV. %{link}." no_channel_hint_html: "Sans chaîne connectée, l'application utilise la chaîne Match Live TV. %{link}."
link_channel: Connecter la chaîne (page du club) link_channel: Connecter la chaîne (page du club)
profile_title: Coordonnées et données fiscales
owner_title: Titulaire du compte
owner_name: Nom
owner_email: E-mail
owner_none: Aucun titulaire associé.
billing_title: Données de facturation
billing_none: Aucune donnée de facturation renseignée.
teams_title: Équipes teams_title: Équipes
no_teams: Aucune équipe enregistrée. no_teams: Aucune équipe enregistrée.
table: table:
@@ -265,6 +275,46 @@ fr:
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours. concurrency_lead: Le même compte a tenté de démarrer un autre direct alors quun était déjà en cours.
concurrency_none: Aucune tentative enregistrée pour ce club. concurrency_none: Aucune tentative enregistrée pour ce club.
concurrency_all: Voir tous les abus de compte concurrency_all: Voir tous les abus de compte
edit_link: Modifier les données
edit:
back: "← Retour au club"
title: "Modifier %{club}"
lead: "Forcez les données saisies par le client : profil, facturation, titulaire, staff, invitations et équipes."
cancel: Annuler
submit: Enregistrer
billing_hint: Vous pouvez enregistrer un profil incomplet ; le badge sur la fiche reflète l'état.
staff_hint: Utilisateurs déjà liés aux équipes du club.
staff_none: Aucun staff lié aux équipes.
invitations_hint: Uniquement les invitations encore en attente d'acceptation.
invitations_none: Aucune invitation en attente.
sections:
club: Profil du club
billing: Données de facturation
owner: Titulaire du compte
staff: Staff accepté
invitations: Invitations en attente
teams: Équipes
fields:
name: Nom du club
sport: Sport
primary_color: Couleur primaire
secondary_color: Couleur secondaire
logo_url: URL du logo
owner_name: Nom du titulaire
owner_email: E-mail du titulaire
staff_name: Nom
staff_email: E-mail
invitation_team: Équipe
invitation_email: E-mail d'invitation
team_name: Nom de l'équipe
team_sport: Sport de l'équipe
errors:
owner_missing: Aucun titulaire associé à ce club.
email_taken: "E-mail déjà utilisé : %{email}"
billing_status:
complete: Complet
incomplete: Incomplet
absent: Absentes
comped: comped:
title: Abonnement offert title: Abonnement offert
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment." description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
@@ -415,6 +465,8 @@ fr:
any: Tous any: Tous
apply: Filtrer apply: Filtrer
reset: Réinitialiser reset: Réinitialiser
chart_path: Page (graphique)
chart_path_all: Toutes les pages
layer: Couche layer: Couche
layer_move: Mouvements souris layer_move: Mouvements souris
layer_click: Clics layer_click: Clics
@@ -427,6 +479,17 @@ fr:
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.
none: Aucune donnée sur la période sélectionnée. none: Aucune donnée sur la période sélectionnée.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Graphique
trend_title: Évolution des pages vues
trend_lead: Total des pages vues sur toutes les pages, jour par jour. Mêmes filtres De / À / Appareil. Choisissez une page dans le filtre ou cliquez « Graphique » dans le tableau.
trend_lead_path: "Pages vues jour par jour pour %{path} uniquement (mêmes filtres De / À / Appareil)."
trend_total_label: Pages vues totales (toutes les pages)
trend_total_label_path: Pages vues de la page sélectionnée
trend_total_hint: "%{from} → %{to}"
hide_chart: Masquer le graphique
show_chart: Afficher le graphique
pages_title: Détail par page
pages_lead: Pages vues et engagement sur la période filtrée, par chemin. « Graphique » montre l’évolution dune page.
table: table:
path: Page path: Page
pageviews: Pages vues pageviews: Pages vues
+62
View File
@@ -52,6 +52,7 @@ it:
quote_revoked: "Prezzo concordato revocato per %{club}." quote_revoked: "Prezzo concordato revocato per %{club}."
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}." transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato. transfer_cancelled: Bonifico in attesa annullato.
club_updated: "Dati aggiornati per %{club}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
announcement_created: Avviso salvato announcement_created: Avviso salvato
announcement_updated: Avviso aggiornato announcement_updated: Avviso aggiornato
@@ -243,6 +244,7 @@ it:
club: Società club: Società
plan: Piano plan: Piano
teams: Squadre teams: Squadre
billing_profile: Dati fiscali
comped: Omaggio comped: Omaggio
stripe: Stripe stripe: Stripe
quote: Concordato quote: Concordato
@@ -259,6 +261,13 @@ it:
youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV) youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV)
no_channel_hint_html: "Senza canale collegato, lapp usa il canale Match Live TV. %{link}." no_channel_hint_html: "Senza canale collegato, lapp usa il canale Match Live TV. %{link}."
link_channel: Collega canale (pagina società) link_channel: Collega canale (pagina società)
profile_title: Anagrafica e dati fiscali
owner_title: Titolare account
owner_name: Nome
owner_email: Email
owner_none: Nessun titolare associato.
billing_title: Dati di fatturazione
billing_none: Nessun dato di fatturazione compilato.
teams_title: Squadre teams_title: Squadre
no_teams: Nessuna squadra registrata. no_teams: Nessuna squadra registrata.
table: table:
@@ -270,6 +279,46 @@ it:
concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una. concurrency_lead: Stesso account che ha provato ad avviare unaltra diretta mentre ne era già in corso una.
concurrency_none: Nessun tentativo registrato per questa società. concurrency_none: Nessun tentativo registrato per questa società.
concurrency_all: Vedi tutti gli abusi account concurrency_all: Vedi tutti gli abusi account
edit_link: Modifica dati
edit:
back: "← Torna alla società"
title: "Modifica %{club}"
lead: "Forza i dati inseriti dal cliente: anagrafica, fatturazione, titolare, staff, inviti e squadre."
cancel: Annulla
submit: Salva modifiche
billing_hint: Puoi salvare anche un profilo incompleto; il badge in scheda rifletterà lo stato.
staff_hint: Utenti già collegati alle squadre della società (inclusi eventuali titolari).
staff_none: Nessuno staff collegato alle squadre.
invitations_hint: Solo inviti ancora in attesa di accettazione.
invitations_none: Nessun invito in attesa.
sections:
club: Anagrafica società
billing: Dati di fatturazione
owner: Titolare account
staff: Staff e invitati accettati
invitations: Inviti in attesa
teams: Squadre
fields:
name: Nome società
sport: Sport
primary_color: Colore primario
secondary_color: Colore secondario
logo_url: URL logo
owner_name: Nome titolare
owner_email: Email titolare
staff_name: Nome
staff_email: Email
invitation_team: Squadra
invitation_email: Email invito
team_name: Nome squadra
team_sport: Sport squadra
errors:
owner_missing: Nessun titolare associato a questa società.
email_taken: "Email già in uso: %{email}"
billing_status:
complete: Completo
incomplete: Incompleto
absent: Assenti
comped: comped:
title: Abbonamento omaggio title: Abbonamento omaggio
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento." description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
@@ -436,6 +485,8 @@ it:
any: Tutti any: Tutti
apply: Filtra apply: Filtra
reset: Azzera reset: Azzera
chart_path: Pagina (grafico)
chart_path_all: Tutte le pagine
layer: Livello layer: Livello
layer_move: Movimenti mouse layer_move: Movimenti mouse
layer_click: Click layer_click: Click
@@ -448,6 +499,17 @@ it:
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.
none: Nessun dato nel periodo selezionato. none: Nessun dato nel periodo selezionato.
heatmap: Heatmap heatmap: Heatmap
chart_for_path: Grafico
trend_title: Andamento pageview
trend_lead: Totale pageview di tutte le pagine, giorno per giorno. Usa gli stessi filtri Da / A / Device. Scegli una pagina nel filtro oppure clicca «Grafico» in tabella.
trend_lead_path: "Pageview giorno per giorno solo per %{path} (stessi filtri Da / A / Device)."
trend_total_label: Pageview totali (tutte le pagine)
trend_total_label_path: Pageview della pagina selezionata
trend_total_hint: "%{from} → %{to}"
hide_chart: Nascondi grafico
show_chart: Mostra grafico
pages_title: Dettaglio per pagina
pages_lead: Pageview e engagement nel periodo filtrato, spezzati per path. Usa «Grafico» per vedere landamento di una sola pagina.
table: table:
path: Pagina path: Pagina
pageviews: Pageview pageviews: Pageview
+1 -1
View File
@@ -101,7 +101,7 @@ Rails.application.routes.draw do
post "billing/transfers/:id/confirm", to: "billing#confirm_transfer", as: :billing_transfer_confirm post "billing/transfers/:id/confirm", to: "billing#confirm_transfer", as: :billing_transfer_confirm
post "billing/transfers/:id/cancel", to: "billing#cancel_transfer", as: :billing_transfer_cancel post "billing/transfers/:id/cancel", to: "billing#cancel_transfer", as: :billing_transfer_cancel
resources :teams, only: %i[show] resources :teams, only: %i[show]
resources :clubs, only: %i[index show] do resources :clubs, only: %i[index show edit update] do
member do member do
post :grant_comped post :grant_comped
delete :revoke_comped delete :revoke_comped
+89
View File
@@ -0,0 +1,89 @@
(function () {
var STORAGE_KEY = "mltv_admin_analytics_chart_hidden";
var trend = window.adminAnalyticsTrend || [];
var i18n = window.adminAnalyticsI18n || {};
var panel = document.getElementById("admin-analytics-trend");
var canvas = document.getElementById("chart-analytics-trend");
var toggle = document.getElementById("admin-analytics-trend-toggle");
var body = document.getElementById("admin-analytics-trend-body");
function setHidden(hidden) {
if (!panel || !body || !toggle) return;
body.hidden = hidden;
panel.classList.toggle("is-collapsed", hidden);
toggle.setAttribute("aria-expanded", hidden ? "false" : "true");
toggle.textContent = hidden
? (i18n.showChart || "Mostra grafico")
: (i18n.hideChart || "Nascondi grafico");
try {
localStorage.setItem(STORAGE_KEY, hidden ? "1" : "0");
} catch (e) { /* ignore */ }
}
function initiallyHidden() {
try {
return localStorage.getItem(STORAGE_KEY) === "1";
} catch (e) {
return false;
}
}
if (toggle && body) {
setHidden(initiallyHidden());
toggle.addEventListener("click", function () {
setHidden(!body.hidden);
});
}
if (!canvas || !trend.length || typeof Chart === "undefined") return;
function dayLabel(dayStr) {
var parts = dayStr.split("-");
if (parts.length < 3) return dayStr;
var d = new Date(parseInt(parts[0], 10), parseInt(parts[1], 10) - 1, parseInt(parts[2], 10));
return d.toLocaleDateString([], { day: "2-digit", month: "short" });
}
new Chart(canvas, {
type: "line",
data: {
labels: trend.map(function (row) { return dayLabel(row.day); }),
datasets: [
{
label: i18n.pageviews || "Pageviews",
data: trend.map(function (r) { return r.pageviews || 0; }),
borderColor: "rgba(229, 57, 53, 0.95)",
backgroundColor: "rgba(229, 57, 53, 0.18)",
fill: true,
tension: 0.25,
pointRadius: 3,
pointHoverRadius: 5
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
interaction: { mode: "index", intersect: false },
animation: { duration: 280 },
scales: {
x: {
ticks: { maxTicksLimit: 14, color: "#9a9aad", font: { size: 10 } },
grid: { color: "rgba(255,255,255,0.06)" }
},
y: {
beginAtZero: true,
ticks: {
color: "#9a9aad",
font: { size: 10 },
precision: 0
},
grid: { color: "rgba(255,255,255,0.06)" }
}
},
plugins: {
legend: { display: false }
}
}
});
})();
+80
View File
@@ -173,6 +173,44 @@ body.admin-body {
height: 200px; height: 200px;
} }
.chart-wrap--analytics {
height: 260px;
margin-top: 0.75rem;
}
.admin-analytics-trend__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.admin-analytics-trend__head h3 {
margin: 0 0 0.35rem;
}
.admin-analytics-trend__kpi {
margin: 0.75rem 0 0;
}
.admin-analytics-trend.is-collapsed .admin-analytics-trend__head {
margin-bottom: 0;
}
.admin-analytics-pages-title {
margin: 0 0 0.35rem;
font-size: 1rem;
}
.admin-table tr.is-chart-focus td {
background: rgba(229, 57, 53, 0.12);
}
.admin-analytics-row-actions {
white-space: nowrap;
}
.admin-panels { .admin-panels {
display: grid; display: grid;
grid-template-columns: 1.4fr 1fr; grid-template-columns: 1.4fr 1fr;
@@ -584,6 +622,35 @@ body.admin-body {
word-break: break-word; word-break: break-word;
} }
.admin-billing-status {
display: inline-block;
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.02em;
padding: 0.15rem 0.5rem;
border-radius: 999px;
border: 1px solid #3a3a45;
color: #ccc;
}
.admin-billing-status--complete {
border-color: #2e7d32;
color: #a5f0b8;
background: #1b3d1b;
}
.admin-billing-status--incomplete {
border-color: #f9a825;
color: #ffe082;
background: #3d3210;
}
.admin-billing-status--absent {
border-color: #555;
color: #999;
background: #1a1a22;
}
.billing-upload-form { .billing-upload-form {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -614,6 +681,19 @@ body.admin-body {
max-width: 720px; max-width: 720px;
} }
.admin-form--wide {
max-width: 920px;
}
.admin-form .panel {
max-width: none;
}
.admin-form .panel h3 {
margin: 0 0 0.85rem;
font-size: 1rem;
}
.admin-form-row { .admin-form-row {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
+92 -3
View File
@@ -1183,6 +1183,93 @@ body.invite-editor--resizing {
.tournament-hub-hint { color: #888; margin: 0 0 12px; } .tournament-hub-hint { color: #888; margin: 0 0 12px; }
.tournament-table-wrap { overflow-x: auto; margin-top: 8px; } .tournament-table-wrap { overflow-x: auto; margin-top: 8px; }
.tournament-match-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
min-width: 0;
overflow-x: clip;
}
.tournament-match-row {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.9fr) minmax(0, 0.7fr);
gap: 10px 12px;
align-items: end;
min-width: 0;
padding: 14px;
border: 1px solid #2a2a36;
border-radius: 12px;
background: #14141c;
}
.tournament-match-row__field,
.tournament-match-row__side,
.tournament-match-row__score {
min-width: 0;
}
.tournament-match-row__field label,
.tournament-match-row__label {
display: block;
margin-bottom: 6px;
color: #9a9aa8;
font-size: 0.75rem;
letter-spacing: 0.02em;
}
.tournament-match-row__phase {
margin: 0;
padding: 8px 0;
color: #e8e8ee;
font-size: 0.95rem;
}
.tournament-match-row__sides {
grid-column: 1 / -1;
display: grid;
grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr) auto;
gap: 8px 10px;
align-items: end;
min-width: 0;
}
.tournament-match-row__actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.tournament-match-row .tournament-match-form__datetime,
.tournament-match-row .tournament-match-form__court,
.tournament-match-row .tournament-match-form__team {
width: 100%;
max-width: 100%;
min-width: 0;
margin-bottom: 0;
padding: 8px 10px;
font-size: 0.9rem;
box-sizing: border-box;
}
@media (max-width: 799px) {
.tournament-match-row {
grid-template-columns: 1fr 1fr;
}
.tournament-match-row__field--phase {
grid-column: 1 / -1;
}
.tournament-match-row__sides {
grid-template-columns: minmax(0, 1fr) 44px minmax(0, 1fr);
}
.tournament-match-row__score {
grid-column: 1 / -1;
}
}
@media (max-width: 560px) {
.tournament-match-row,
.tournament-match-row__sides {
grid-template-columns: minmax(0, 1fr);
}
.tournament-match-row__field--phase {
grid-column: auto;
}
.tournament-match-swap-col {
justify-self: center;
}
}
.tournament-index-actions { .tournament-index-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -1205,19 +1292,19 @@ body.invite-editor--resizing {
background: #12121a; background: #12121a;
flex-shrink: 0; flex-shrink: 0;
} }
.tournament-match-table .tournament-match-form__datetime { table.tournament-match-table .tournament-match-form__datetime {
min-width: 190px; min-width: 190px;
margin-bottom: 0; margin-bottom: 0;
padding: 8px 10px; padding: 8px 10px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.tournament-match-table .tournament-match-form__court { table.tournament-match-table .tournament-match-form__court {
min-width: 120px; min-width: 120px;
margin-bottom: 0; margin-bottom: 0;
padding: 8px 10px; padding: 8px 10px;
font-size: 0.9rem; font-size: 0.9rem;
} }
.tournament-match-table .tournament-match-form__team { table.tournament-match-table .tournament-match-form__team {
min-width: 160px; min-width: 160px;
max-width: 220px; max-width: 220px;
margin-bottom: 0; margin-bottom: 0;
@@ -1260,6 +1347,8 @@ body.invite-editor--resizing {
padding: 4px 10px; padding: 4px 10px;
font-size: 0.8rem; font-size: 0.8rem;
margin: 0; margin: 0;
flex-shrink: 0;
white-space: nowrap;
} }
.tournament-match-swap-col { .tournament-match-swap-col {
width: 44px; width: 44px;
+1 -1
View File
@@ -10,7 +10,7 @@
event.stopImmediatePropagation(); event.stopImmediatePropagation();
if (form.dataset.swapBusy === "1") return; if (form.dataset.swapBusy === "1") return;
var row = form.closest("tr"); var row = form.closest(".tournament-match-row") || form.closest("tr");
if (!row) return; if (!row) return;
form.dataset.swapBusy = "1"; form.dataset.swapBusy = "1";
@@ -41,4 +41,15 @@ RSpec.describe ClubBillingProfile do
club.billing_fiscal_code = "RSSMRA80A01H501U" club.billing_fiscal_code = "RSSMRA80A01H501U"
expect(club.billing_profile_complete?).to be true expect(club.billing_profile_complete?).to be true
end end
it "classifies admin billing status" do
expect(club.billing_profile_admin_status).to eq(:complete)
club.billing_recipient_code = nil
club.billing_pec = nil
expect(club.billing_profile_admin_status).to eq(:incomplete)
empty = Club.create!(name: "Empty", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff")
expect(empty.billing_profile_admin_status).to eq(:absent)
end
end end
+80 -2
View File
@@ -83,15 +83,93 @@ RSpec.describe Match, "scheduling scopes" do
expect(match.coach_hub_visible?).to be(false) expect(match.coach_hub_visible?).to be(false)
end end
it "nasconde partita programmata nel passato mai trasmessa" do it "nasconde partita con risultato inserito dal portale anche senza diretta" do
match = team.matches.create!( match = team.matches.create!(
opponent_name: "Missed", opponent_name: "Squadra 4",
scheduled_at: 2.hours.ago,
sets_to_win: 3,
home_score: 11,
away_score: 10,
result_status: "played"
)
expect(match.coach_hub_visible?).to be(false)
end
it "nasconde walkover anche se il torneo è ancora aperto" do
broadcast = club.teams.create!(
name: "Torneo Walkover",
sport_key: "pallavolo",
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
tournament = Tournament.create!(
club: club,
name: "Torneo Walkover",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
status: "published",
broadcast_team: broadcast
)
match = broadcast.matches.create!(
opponent_name: "TBD",
scheduled_at: 1.hour.ago,
sets_to_win: 3,
tournament: tournament,
result_status: "walkover_home",
home_score: 1,
away_score: 0
)
expect(match.coach_hub_visible?).to be(false)
end
it "mostra partita di oggi già passata d'orario se non è stata trasmessa" do
match = team.matches.create!(
opponent_name: "Late",
scheduled_at: 2.hours.ago, scheduled_at: 2.hours.ago,
sets_to_win: 3 sets_to_win: 3
) )
expect(match.coach_hub_visible?).to be(true)
end
it "nasconde partita di un giorno precedente mai trasmessa" do
match = team.matches.create!(
opponent_name: "Yesterday",
scheduled_at: 1.day.ago.change(hour: 10),
sets_to_win: 3
)
expect(match.coach_hub_visible?).to be(false) expect(match.coach_hub_visible?).to be(false)
end end
it "mostra gara di torneo in corso anche se l'orario era ieri" do
broadcast = club.teams.create!(
name: "Torneo Late",
sport_key: "pallavolo",
internal_kind: Tournament::INTERNAL_TEAM_KIND
)
tournament = Tournament.create!(
club: club,
name: "Torneo Late",
sport_key: "pallavolo",
starts_on: Date.yesterday,
ends_on: Date.current,
format_kind: "free",
status: "published",
broadcast_team: broadcast
)
match = broadcast.matches.create!(
opponent_name: "TBD",
scheduled_at: 1.day.ago.change(hour: 10),
sets_to_win: 3,
tournament: tournament
)
expect(match.coach_hub_visible?).to be(true)
end
end end
describe "scoring_rules" do describe "scoring_rules" do
+41
View File
@@ -31,4 +31,45 @@ RSpec.describe Recording do
rec = described_class.create!(stream_session: session, team: team, status: "processing") rec = described_class.create!(stream_session: session, team: team, status: "processing")
expect(rec.title_or_default).to eq("Team vs Rival") expect(rec.title_or_default).to eq("Team vs Rival")
end end
it "usa le squadre del tabellone al posto della squadra di trasmissione" do
load Rails.root.join("db/seeds/plans.rb")
Billing::AssignPlan.call(club: club, plan_slug: "premium_full")
ClubMembership.create!(user: user, club: club, role: "owner")
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Memorial",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free"
}
)
home = tournament.participants.create!(name: "Gamma")
away = tournament.participants.create!(name: "Beta")
tournament_match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
scheduled_at: 1.hour.from_now
}
)
tournament_session = StreamSession.create!(
match: tournament_match,
user: user,
platform: "matchlivetv",
status: "ended",
privacy_status: "public"
)
rec = described_class.create!(
stream_session: tournament_session,
team: tournament.broadcast_team,
status: "ready",
title: "#{tournament.broadcast_team.name} vs Beta"
)
expect(rec.title_or_default).to eq("Gamma vs Beta")
expect(rec.default_title).to eq("Gamma vs Beta")
end
end end
@@ -7,6 +7,8 @@ RSpec.describe "Admin analytics", type: :request do
before do before do
post admin_login_path, params: { username: admin.username, password: "Password123" } post admin_login_path, params: { username: admin.username, password: "Password123" }
AnalyticsPageStat.delete_all
AnalyticsPageCell.delete_all
AnalyticsPageStat.create!( AnalyticsPageStat.create!(
day: Time.zone.today, day: Time.zone.today,
page_path: "/prezzi", page_path: "/prezzi",
@@ -33,8 +35,62 @@ RSpec.describe "Admin analytics", type: :request do
expect(response.body).to include("/prezzi") expect(response.body).to include("/prezzi")
expect(response.body).to include("12") expect(response.body).to include("12")
expect(response.body).to include("40") expect(response.body).to include("40")
expect(response.body).to include("chart-analytics-trend")
expect(response.body).to include("adminAnalyticsTrend")
expect(response.body).to include(I18n.t("admin.analytics.index.hide_chart"))
expect(response.body).to include("hideChart")
expect(response.body).not_to include("clicks:")
end end
it "il grafico rispetta i filtri data e aggrega pageview di tutte le pagine" do
AnalyticsPageStat.create!(
day: 1.day.ago.to_date,
page_path: "/prezzi",
device: "desktop",
pageview_count: 5,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/altro",
device: "desktop",
pageview_count: 7,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
get admin_analytics_path, params: { from: 2.days.ago.to_date, to: Time.zone.today }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.analytics.index.trend_total_label"))
expect(response.body).to include(">24<")
expect(response.body).to include("\"pageviews\":5")
expect(response.body).to include("\"pageviews\":19")
end
it "filtra il grafico su una sola pagina" do
AnalyticsPageStat.create!(
day: Time.zone.today,
page_path: "/altro",
device: "desktop",
pageview_count: 7,
scroll_samples: 0,
scroll_sum_pct: 0,
max_scroll_pct: 0
)
get admin_analytics_path, params: { chart_path: "/prezzi" }
expect(response).to have_http_status(:ok)
expect(response.body).to include(I18n.t("admin.analytics.index.trend_total_label_path"))
expect(response.body).to include(">12<")
expect(response.body).to include(I18n.t("admin.analytics.index.chart_for_path"))
expect(response.body).to include("is-chart-focus")
expect(response.body).not_to include(">19<")
end
it "mostra la heatmap di una pagina con tab dispositivo" 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)
@@ -0,0 +1,95 @@
require "rails_helper"
RSpec.describe "Admin clubs billing profile", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-clubs-billing", password: "Password123") }
let!(:complete_club) do
Club.create!(
name: "Club Completo",
sport: "volleyball",
primary_color: "#e53935",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Completo",
billing_email: "fatture@completo.it",
billing_address_line: "Via Roma 1",
billing_city: "Milano",
billing_province: "MI",
billing_postal_code: "20100",
billing_country: "IT",
billing_vat_number: "12345678901",
billing_recipient_code: "ABCDEFG"
)
end
let!(:incomplete_club) do
Club.create!(
name: "Club Incompleto",
sport: "volleyball",
primary_color: "#e53935",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Incompleto",
billing_vat_number: "10987654321"
)
end
let!(:absent_club) do
Club.create!(
name: "Club Assente",
sport: "volleyball",
primary_color: "#e53935",
secondary_color: "#ffffff"
)
end
let!(:owner) do
user = User.create!(email: "owner-completo@test.it", name: "Mario Rossi", password: "Password123", role: "coach")
ClubMembership.create!(user: user, club: complete_club, role: "owner")
user
end
before do
post admin_login_path, params: { username: "ops-clubs-billing", password: "Password123" }
end
it "mostra i dati fiscali e il titolare nella scheda società" do
get admin_club_path(complete_club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Anagrafica e dati fiscali")
expect(response.body).to include("Mario Rossi")
expect(response.body).to include("owner-completo@test.it")
expect(response.body).to include("ASD Completo")
expect(response.body).to include("12345678901")
expect(response.body).to include("ABCDEFG")
expect(response.body).to include("Completo")
end
it "mostra lo stato incompleto con errori mancanti" do
get admin_club_path(incomplete_club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Incompleto")
expect(response.body).to include("ASD Incompleto")
expect(response.body).to include("10987654321")
end
it "mostra lo stato assente quando non ci sono dati fiscali" do
get admin_club_path(absent_club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Assenti")
expect(response.body).to include("Nessun dato di fatturazione compilato.")
end
it "elenca lo stato del profilo fiscale nella lista società" do
get admin_clubs_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Dati fiscali")
expect(response.body).to include("Completo")
expect(response.body).to include("Incompleto")
expect(response.body).to include("Assenti")
end
end
@@ -0,0 +1,104 @@
require "rails_helper"
RSpec.describe "Admin club data edit", type: :request do
let!(:admin) { AdminAccount.create!(username: "ops-club-edit", password: "Password123") }
let!(:club) do
Club.create!(
name: "Edit Club",
sport: "pallavolo",
primary_color: "#e53935",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Edit",
billing_email: "old-bill@test.it",
billing_address_line: "Via 1",
billing_city: "Milano",
billing_province: "MI",
billing_postal_code: "20100",
billing_country: "IT",
billing_vat_number: "12345678901",
billing_recipient_code: "ABCDEFG"
)
end
let!(:owner) do
user = User.create!(email: "owner-typo@test.it", name: "Owner Old", password: "Password123", role: "coach")
ClubMembership.create!(user: user, club: club, role: "owner")
user
end
let!(:team) { club.teams.create!(name: "U14", sport: "pallavolo") }
let!(:staff) do
user = User.create!(email: "staff@test.it", name: "Staff Old", password: "Password123", role: "coach")
UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission")
user
end
let!(:invitation) do
token = TeamInvitation.generate_token
TeamInvitation.create!(
team: team,
email: "invite-old@test.it",
token_digest: Digest::SHA256.hexdigest(token),
role: "member",
staff_kind: "transmission",
expires_at: 3.days.from_now
)
end
before do
post admin_login_path, params: { username: "ops-club-edit", password: "Password123" }
end
it "mostra il form di modifica" do
get edit_admin_club_path(club)
expect(response).to have_http_status(:ok)
expect(response.body).to include("Modifica Edit Club")
expect(response.body).to include("owner-typo@test.it")
expect(response.body).to include("invite-old@test.it")
end
it "forza anagrafica, billing, owner, staff, invito e squadra" do
patch admin_club_path(club), params: {
club: {
name: "Edit Club Nuovo",
sport: "pallavolo",
primary_color: "#112233",
secondary_color: "#ffffff",
billing_entity_type: "company",
billing_legal_name: "ASD Edit Nuova",
billing_email: "amministrazione@test.it",
billing_vat_number: "12345678901",
billing_address_line: "Via Roma 10",
billing_city: "Milano",
billing_province: "mi",
billing_postal_code: "20121",
billing_country: "it",
billing_recipient_code: "ABCDEFG"
},
owner: { name: "Owner Fixed", email: "amministrazione@test.it" },
staff_users: {
staff.id => { name: "Staff Fixed", email: "staff-fixed@test.it" }
},
invitations: {
invitation.id => { email: "invite-fixed@test.it" }
},
teams: {
team.id => { name: "U15", sport: "pallavolo" }
}
}
expect(response).to redirect_to(admin_club_path(club))
follow_redirect!
expect(response.body).to include("Dati aggiornati per Edit Club Nuovo")
club.reload
expect(club.name).to eq("Edit Club Nuovo")
expect(club.billing_email).to eq("amministrazione@test.it")
expect(club.billing_province).to eq("MI")
expect(club.billing_legal_name).to eq("ASD Edit Nuova")
expect(owner.reload.email).to eq("amministrazione@test.it")
expect(owner.name).to eq("Owner Fixed")
expect(staff.reload.email).to eq("staff-fixed@test.it")
expect(invitation.reload.email).to eq("invite-fixed@test.it")
expect(team.reload.name).to eq("U15")
end
end
@@ -0,0 +1,37 @@
require "rails_helper"
RSpec.describe "Public match destroy with recording", type: :request do
let!(:user) { User.create!(email: "match-del@test.it", name: "Del", password: "Password123", role: "coach") }
let!(:club) { Club.create!(name: "Del Club", sport: "pallavolo", primary_color: "#e53935", secondary_color: "#ffffff") }
let!(:team) { club.teams.create!(name: "U14", sport: "pallavolo") }
let!(:match) { team.matches.create!(opponent_name: "Rival", sport: "pallavolo") }
let!(:session) do
StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "ended", ended_at: 1.hour.ago)
end
let!(:recording) do
Recording.create!(
stream_session: session,
team: team,
status: "ready",
storage_backend: "local",
storage_policy: "retained",
privacy_status: "unlisted"
)
end
before do
ClubMembership.create!(user: user, club: club, role: "owner")
UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission")
post public_login_path, params: { email: user.email, password: "Password123" }
end
it "cancella la partita anche se esiste un recording collegato alla sessione" do
expect do
delete public_team_match_path(team, match)
end.to change(Match, :count).by(-1)
.and change(StreamSession, :count).by(-1)
.and change(Recording, :count).by(-1)
expect(response).to redirect_to(public_team_matches_path(team))
end
end
@@ -366,6 +366,7 @@ RSpec.describe "Tournaments", type: :request do
get public_tournament_path(tournament, tab: "calendario") get public_tournament_path(tournament, tab: "calendario")
expect(response.body).to include("tournament-match-swap__btn") expect(response.body).to include("tournament-match-swap__btn")
expect(response.body).to include("tournament-match-row")
expect(response.body).to include(public_swap_tournament_match_path(tournament, match)) expect(response.body).to include(public_swap_tournament_match_path(tournament, match))
expect(response.body).to include("tournament-calendar.js") expect(response.body).to include("tournament-calendar.js")
@@ -767,7 +768,7 @@ RSpec.describe "Tournaments", type: :request do
session.update_columns(status: "ended", ended_at: Time.current) session.update_columns(status: "ended", ended_at: Time.current)
Recording.create!( Recording.create!(
stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public", stream_session: session, team: tournament.broadcast_team, status: "ready", privacy_status: "public",
title: "Replay Alfa-Beta", title: "#{tournament.broadcast_team.name} vs #{away.name}",
storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4" storage_key: "teams/#{tournament.broadcast_team_id}/sessions/#{session.id}/replay.mp4"
) )
@@ -775,9 +776,89 @@ RSpec.describe "Tournaments", type: :request do
expect(response.body).to include("Guarda replay") expect(response.body).to include("Guarda replay")
expect(response.body).to include(public_replay_path(session.id)) expect(response.body).to include(public_replay_path(session.id))
expect(response.body).to include("tournament-live-status--replay") expect(response.body).to include("tournament-live-status--replay")
expect(response.body).to include("Replay Alfa-Beta") expect(response.body).to include("#{home.name} vs #{away.name}")
expect(response.body).not_to include("#{tournament.broadcast_team.name} vs #{away.name}")
get public_club_tournaments_path(club) get public_club_tournaments_path(club)
expect(response.body).to include(public_tournament_page_path(tournament.slug)) expect(response.body).to include(public_tournament_page_path(tournament.slug))
end end
it "marca In diretta una gara YouTube o in collegamento, non In attesa" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Diretta YouTube",
sport_key: "basket",
starts_on: Date.current,
ends_on: Date.current,
format_kind: "free",
courts: "Campo A"
}
)
home = tournament.participants.create!(name: "Squadra 1")
away = tournament.participants.create!(name: "Squadra 3")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo A",
scheduled_at: 30.minutes.ago
}
)
post public_publish_tournament_path(tournament)
session = StreamSession.create!(
match: match, user: coach, platform: "youtube", status: "connecting",
privacy_status: "unlisted", youtube_broadcast_id: "ytLiveBoard1"
)
get public_tournament_page_path(tournament.slug)
expect(response.body).to include("tournament-live-status--live")
expect(response.body).to include("In diretta")
expect(response.body).not_to include("tournament-live-status--waiting")
expect(response.body).to include("Guarda su YouTube")
expect(response.body).to include(session.youtube_watch_url)
get public_tournament_path(tournament, tab: "dirette")
expect(response.body).to include("tournament-live-status--live")
expect(response.body).to include("In diretta")
expect(response.body).not_to include("tournament-live-status--waiting_operator")
end
it "marca Terminata una gara con risultato anche se l'orario è ancora futuro" do
assign_plan!("premium_full")
login!
tournament = Tournaments::Create.call(
club: club,
attrs: {
name: "Open Risultato Anticipato",
sport_key: "pallavolo",
starts_on: Date.current,
ends_on: Date.current + 1,
format_kind: "free",
courts: "Campo 1"
}
)
home = tournament.participants.create!(name: "Alfa")
away = tournament.participants.create!(name: "Delta")
match = Tournaments::ScheduleMatch.call(
tournament: tournament,
attrs: {
home_participant_id: home.id,
away_participant_id: away.id,
court: "Campo 1",
scheduled_at: 6.hours.from_now
}
)
Tournaments::RecordResult.call(match: match, home_score: 2, away_score: 0, source: "manual")
post public_publish_tournament_path(tournament)
get public_tournament_page_path(tournament.slug, tab: "risultati")
expect(response.body).to include("20")
expect(response.body).to include("tournament-live-status--ended")
expect(response.body).to include("Terminata")
expect(response.body).not_to include("tournament-live-status--scheduled")
end
end end
@@ -0,0 +1,46 @@
require "rails_helper"
RSpec.describe Admin::UpdateClubData do
let!(:club) do
Club.create!(
name: "Svc Club",
sport: "pallavolo",
primary_color: "#e53935",
secondary_color: "#ffffff"
)
end
let!(:owner) do
user = User.create!(email: "svc-owner@test.it", name: "Owner", password: "Password123", role: "coach")
ClubMembership.create!(user: user, club: club, role: "owner")
user
end
it "updates club and owner email" do
described_class.call(
club: club,
club_attrs: {
name: "Svc Club Updated",
billing_email: "bill@test.it",
billing_province: "bz"
},
owner_attrs: { email: "owner-fixed@test.it", name: "Owner Fixed" }
)
expect(club.reload.name).to eq("Svc Club Updated")
expect(club.billing_email).to eq("bill@test.it")
expect(club.billing_province).to eq("BZ")
expect(owner.reload.email).to eq("owner-fixed@test.it")
end
it "raises when email is already taken" do
User.create!(email: "taken@test.it", name: "Other", password: "Password123", role: "coach")
expect do
described_class.call(
club: club,
club_attrs: {},
owner_attrs: { email: "taken@test.it" }
)
end.to raise_error(Admin::UpdateClubData::Error)
end
end
@@ -28,6 +28,7 @@ RSpec.describe Recordings::FinalizeSession do
expect(rec.storage_policy).to eq("retained") expect(rec.storage_policy).to eq("retained")
expect(rec.expires_at).to be > 29.days.from_now expect(rec.expires_at).to be > 29.days.from_now
expect(rec.metadata["auto_publish_youtube"]).to eq(false) expect(rec.metadata["auto_publish_youtube"]).to eq(false)
expect(rec.title).to eq("Team vs Rival")
end end
it "skips free plan" do it "skips free plan" do
+2 -2
View File
@@ -24,8 +24,8 @@ android {
applicationId = "com.matchlivetv.match_live_tv" applicationId = "com.matchlivetv.match_live_tv"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 33 versionCode = 34
versionName = "2.0.12-native" versionName = "2.0.13-native"
val apiBaseUrl = project.findProperty("API_BASE_URL") as String? val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
?: "https://www.matchlivetv.it" ?: "https://www.matchlivetv.it"
@@ -51,3 +51,11 @@ private fun parseInstantOrNull(value: String): Instant? {
} }
fun Instant.isScheduledFuture(now: Instant = Instant.now()): Boolean = isAfter(now) fun Instant.isScheduledFuture(now: Instant = Instant.now()): Boolean = isAfter(now)
fun Instant.isScheduledOnCalendar(
now: Instant = Instant.now(),
zone: ZoneId = ZoneId.of("Europe/Rome"),
): Boolean {
val startOfToday = now.atZone(zone).toLocalDate().atStartOfDay(zone).toInstant()
return !isBefore(startOfToday)
}
@@ -1,10 +1,10 @@
package com.matchlivetv.match_live_tv.domain package com.matchlivetv.match_live_tv.domain
import com.matchlivetv.match_live_tv.core.isScheduledFuture import com.matchlivetv.match_live_tv.core.isScheduledOnCalendar
import com.matchlivetv.match_live_tv.core.parseApiInstant import com.matchlivetv.match_live_tv.core.parseApiInstant
import java.time.Instant import java.time.Instant
/** Partite visibili nell'hub coach: dirette attive, wizard in corso, programmate future. */ /** Partite visibili nell'hub coach: dirette attive, wizard in corso, in calendario oggi o future. */
fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean { fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
coachHubVisible?.let { return it } coachHubVisible?.let { return it }
@@ -15,7 +15,7 @@ fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
if (streamCompleted) return false if (streamCompleted) return false
val scheduled = parseApiInstant(scheduledAt) val scheduled = parseApiInstant(scheduledAt)
return scheduled != null && scheduled.isScheduledFuture(now) return scheduled != null && scheduled.isScheduledOnCalendar(now)
} }
fun List<Match>.coachHubVisible(now: Instant = Instant.now()): List<Match> = fun List<Match>.coachHubVisible(now: Instant = Instant.now()): List<Match> =
@@ -114,34 +114,38 @@ class CompactScoreboardElement : OverlayElement {
val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor
val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl
val logo = OverlayLogoCache.get(logoUrl) val logo = OverlayLogoCache.get(logoUrl)
val rawName = if (isHome) compact.homeTeamName else compact.awayTeamName val rawName = (if (isHome) compact.homeTeamName else compact.awayTeamName).trim()
val name = ellipsize(rawName.trim(), labelPaint, maxWidth * 0.55f) val barHalfH = logoSize * 0.45f
val textY = centerY + labelPaint.textSize * 0.35f
if (isHome) { if (isHome) {
var x = rectLeft var x = rectLeft
accentBarPaint.color = accentColor accentBarPaint.color = accentColor
canvas.drawRect(x, centerY - logoSize * 0.45f, x + barW, centerY + logoSize * 0.45f, accentBarPaint) canvas.drawRect(x, centerY - barHalfH, x + barW, centerY + barHalfH, accentBarPaint)
x += barW + gap x += barW + gap
if (logo != null) { if (logo != null) {
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f) val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
canvas.drawBitmap(logo, null, dest, logoPaint) canvas.drawBitmap(logo, null, dest, logoPaint)
x += logoSize + gap x += logoSize + gap
} }
canvas.drawText(name, x, centerY + labelPaint.textSize * 0.35f, labelPaint) val name = ellipsize(rawName, labelPaint, (rectRight - x).coerceAtMost(maxWidth))
canvas.drawText(name, x, textY, labelPaint)
} else { } else {
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
var x = rectRight var x = rectRight
accentBarPaint.color = accentColor accentBarPaint.color = accentColor
canvas.drawRect(x - barW, centerY - logoSize * 0.45f, x, centerY + logoSize * 0.45f, accentBarPaint) canvas.drawRect(x - barW, centerY - barHalfH, x, centerY + barHalfH, accentBarPaint)
x -= barW + gap x -= barW + gap
labelPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(name, x, centerY + labelPaint.textSize * 0.35f, labelPaint)
labelPaint.textAlign = Paint.Align.LEFT
x -= gap
if (logo != null) { if (logo != null) {
x -= logoSize x -= logoSize
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f) val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
canvas.drawBitmap(logo, null, dest, logoPaint) canvas.drawBitmap(logo, null, dest, logoPaint)
x -= gap
} }
val name = ellipsize(rawName, labelPaint, (x - rectLeft).coerceAtMost(maxWidth))
labelPaint.textAlign = Paint.Align.RIGHT
canvas.drawText(name, x, textY, labelPaint)
labelPaint.textAlign = Paint.Align.LEFT
} }
} }
@@ -47,8 +47,6 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.R import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.core.isScheduledFuture
import com.matchlivetv.match_live_tv.core.parseApiInstant
import com.matchlivetv.match_live_tv.data.AppContainer import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match import com.matchlivetv.match_live_tv.domain.Match
@@ -161,7 +159,7 @@ fun MatchesScreen(
(match.hasActiveSession && match.activeSessionStatus == "idle") (match.hasActiveSession && match.activeSessionStatus == "idle")
} }
val scheduledMatches = matches.filter { match -> val scheduledMatches = matches.filter { match ->
!match.hasActiveSession && parseApiInstant(match.scheduledAt)?.isScheduledFuture() == true !match.hasActiveSession && !match.scheduledAt.isNullOrBlank()
} }
val draftMatches = matches.filter { !it.hasActiveSession && it.scheduledAt == null } val draftMatches = matches.filter { !it.hasActiveSession && it.scheduledAt == null }
val calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id } val calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id }
@@ -10,7 +10,22 @@ class ApiInstantTest {
fun parseApiInstant_railsOffsetWithMillis() { fun parseApiInstant_railsOffsetWithMillis() {
val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00") val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00")
assertNotNull(instant) assertNotNull(instant)
assertTrue(instant!!.isScheduledFuture(Instant.parse("2026-06-06T16:00:00Z"))) assertTrue(instant!!.isScheduledOnCalendar(Instant.parse("2026-06-06T16:00:00Z")))
}
@Test
fun isScheduledOnCalendar_keepsThisMorningAfterKickoff() {
val kickoff = Instant.parse("2026-09-06T07:00:00Z")
val now = Instant.parse("2026-09-06T08:30:00Z")
assertTrue(kickoff.isScheduledOnCalendar(now))
assertTrue(!kickoff.isScheduledFuture(now))
}
@Test
fun isScheduledOnCalendar_hidesYesterday() {
val yesterday = Instant.parse("2026-09-05T10:00:00Z")
val now = Instant.parse("2026-09-06T08:30:00Z")
assertTrue(!yesterday.isScheduledOnCalendar(now))
} }
@Test @Test
@@ -677,11 +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 = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
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;
@@ -698,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 = 35; CURRENT_PROJECT_VERSION = 38;
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;
@@ -709,7 +709,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
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;
@@ -727,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 = 35; CURRENT_PROJECT_VERSION = 38;
DEVELOPMENT_TEAM = S8Q9TWBRG5; DEVELOPMENT_TEAM = S8Q9TWBRG5;
ENABLE_TESTABILITY = YES; ENABLE_TESTABILITY = YES;
GENERATE_INFOPLIST_FILE = NO; GENERATE_INFOPLIST_FILE = NO;
@@ -739,7 +739,7 @@
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
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;
@@ -756,11 +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 = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
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;
@@ -775,11 +775,11 @@
buildSettings = { buildSettings = {
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -794,11 +794,11 @@
buildSettings = { buildSettings = {
CLANG_ENABLE_OBJC_WEAK = YES; CLANG_ENABLE_OBJC_WEAK = YES;
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)"; INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
IPHONEOS_DEPLOYMENT_TARGET = 16.6; IPHONEOS_DEPLOYMENT_TARGET = 16.6;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests"; PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv.uitests";
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos; SDKROOT = iphoneos;
@@ -19,6 +19,14 @@ enum ApiInstant {
return date > now return date > now
} }
static func isScheduledOnCalendar(_ raw: String?, now: Date = Date()) -> Bool {
guard let date = parse(raw) else { return false }
var calendar = Calendar(identifier: .gregorian)
calendar.timeZone = TimeZone(identifier: "Europe/Rome") ?? .current
let startOfToday = calendar.startOfDay(for: now)
return date >= startOfToday
}
static func formatMatchDate(_ raw: String?) -> String? { static func formatMatchDate(_ raw: String?) -> String? {
guard let date = parse(raw) else { return nil } guard let date = parse(raw) else { return nil }
let f = DateFormatter() let f = DateFormatter()
@@ -12,13 +12,17 @@ enum MatchHubFilter {
} }
if match.streamCompleted { return false } if match.streamCompleted { return false }
return ApiInstant.isScheduledFuture(match.scheduledAt, now: now) return ApiInstant.isScheduledOnCalendar(match.scheduledAt, now: now)
} }
static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool { static func isScheduledFuture(_ match: Match, now: Date = Date()) -> Bool {
ApiInstant.isScheduledFuture(match.scheduledAt, now: now) ApiInstant.isScheduledFuture(match.scheduledAt, now: now)
} }
static func isOnCalendar(_ match: Match, now: Date = Date()) -> Bool {
ApiInstant.isScheduledOnCalendar(match.scheduledAt, now: now)
}
static func isDraft(_ match: Match) -> Bool { static func isDraft(_ match: Match) -> Bool {
!match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true) !match.hasActiveSession && (match.scheduledAt == nil || match.scheduledAt?.isEmpty == true)
} }
Binary file not shown.

Before

Width:  |  Height:  |  Size: 216 KiB

After

Width:  |  Height:  |  Size: 215 KiB

+2 -2
View File
@@ -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.12</string> <string>2.0.13</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>35</string> <string>38</string>
<key>LSRequiresIPhoneOS</key> <key>LSRequiresIPhoneOS</key>
<true/> <true/>
<key>NSCameraUsageDescription</key> <key>NSCameraUsageDescription</key>
@@ -67,32 +67,37 @@ struct CompactScoreboardElement: OverlayElement {
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
let logo = OverlayLogoCache.get(logoUrl) let logo = OverlayLogoCache.get(logoUrl)
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName let rawName = (isHome ? compact.homeTeamName : compact.awayTeamName)
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55) .trimmingCharacters(in: .whitespacesAndNewlines)
let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white] let attrs: [NSAttributedString.Key: Any] = [.font: labelFont, .foregroundColor: UIColor.white]
let textY = centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight
let barHalfH = CGFloat(logoSize) * 0.45
if isHome { if isHome {
var x = rectLeft var x = rectLeft
context.setFillColor(accent.cgColor) context.setFillColor(accent.cgColor)
context.fill(CGRect(x: x, y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9)) context.fill(CGRect(x: x, y: centerY - barHalfH, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
x += CGFloat(barW + gap) x += CGFloat(barW + gap)
if let logo, let cg = logo.cgImage { if let logo, let cg = logo.cgImage {
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize))) context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
x += CGFloat(logoSize + gap) x += CGFloat(logoSize + gap)
} }
(name as NSString).draw(at: CGPoint(x: x, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs) let name = ellipsize(rawName, font: labelFont, maxWidth: min(rectRight - x, maxWidth))
(name as NSString).draw(at: CGPoint(x: x, y: textY), withAttributes: attrs)
} else { } else {
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
var x = rectRight var x = rectRight
context.setFillColor(accent.cgColor) context.setFillColor(accent.cgColor)
context.fill(CGRect(x: x - CGFloat(barW), y: centerY - CGFloat(logoSize) * 0.45, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9)) context.fill(CGRect(x: x - CGFloat(barW), y: centerY - barHalfH, width: CGFloat(barW), height: CGFloat(logoSize) * 0.9))
x -= CGFloat(barW + gap) x -= CGFloat(barW + gap)
let nameSize = (name as NSString).size(withAttributes: attrs)
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: centerY + labelFont.pointSize * 0.35 - labelFont.lineHeight), withAttributes: attrs)
x -= CGFloat(gap)
if let logo, let cg = logo.cgImage { if let logo, let cg = logo.cgImage {
x -= CGFloat(logoSize) x -= CGFloat(logoSize)
context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize))) context.draw(cg, in: CGRect(x: x, y: centerY - CGFloat(logoSize) / 2, width: CGFloat(logoSize), height: CGFloat(logoSize)))
x -= CGFloat(gap)
} }
let name = ellipsize(rawName, font: labelFont, maxWidth: min(x - rectLeft, maxWidth))
let nameSize = (name as NSString).size(withAttributes: attrs)
(name as NSString).draw(at: CGPoint(x: x - nameSize.width, y: textY), withAttributes: attrs)
} }
} }
@@ -289,7 +289,7 @@ struct MatchesScreen: View {
} }
private var scheduledMatches: [Match] { private var scheduledMatches: [Match] {
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) } matches.filter { !$0.hasActiveSession && !($0.scheduledAt ?? "").isEmpty }
} }
private var calendarMatches: [Match] { private var calendarMatches: [Match] {
@@ -1,7 +1,45 @@
import XCTest import XCTest
@testable import MatchLiveTv
/// Smoke test eseguibile senza @testable import (validazione logica pura). /// Logica calendario/API reale + smoke parser (RTMP/overlay) senza dipendenze di rete.
final class ApiInstantTests: XCTestCase { final class ApiInstantTests: XCTestCase {
func testParseApiInstantRailsOffsetWithMillis() {
let now = iso("2026-06-06T16:00:00Z")
XCTAssertTrue(ApiInstant.isScheduledOnCalendar("2026-06-06T20:00:00.000+02:00", now: now))
}
func testIsScheduledOnCalendarKeepsThisMorningAfterKickoff() {
let kickoff = "2026-09-06T07:00:00Z"
let now = iso("2026-09-06T08:30:00Z")
XCTAssertTrue(ApiInstant.isScheduledOnCalendar(kickoff, now: now))
XCTAssertFalse(ApiInstant.isScheduledFuture(kickoff, now: now))
}
func testIsScheduledOnCalendarHidesYesterday() {
let yesterday = "2026-09-05T10:00:00Z"
let now = iso("2026-09-06T08:30:00Z")
XCTAssertFalse(ApiInstant.isScheduledOnCalendar(yesterday, now: now))
}
func testConcurrentStreamForbiddenShowsApiMessage() {
let body = """
{"error":"Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra.","error_code":"user_concurrent_stream"}
"""
XCTAssertEqual(
APIError.http(403, body).errorDescription,
"Hai già una diretta in corso con questo account. Chiudila prima di avviarne unaltra."
)
}
private func iso(_ value: String) -> Date {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
guard let date = f.date(from: value) else {
XCTFail("Data ISO non valida: \(value)")
return Date()
}
return date
}
func testOverlayKindMapping() { func testOverlayKindMapping() {
XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket") XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket")
XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none") XCTAssertEqual(OverlayKindMapping.fromApi("none"), "none")
@@ -121,6 +121,65 @@ final class MatchScoringRulesTests: XCTestCase {
} }
} }
final class MatchHubCalendarTests: XCTestCase {
func testHubKeepsMatchAfterKickoffSameDay() {
let now = iso("2026-09-06T08:30:00Z")
let match = hubMatch(scheduledAt: "2026-09-06T07:00:00Z")
XCTAssertTrue(MatchHubFilter.coachHubVisible(match, now: now))
XCTAssertTrue(MatchHubFilter.isOnCalendar(match, now: now))
XCTAssertFalse(MatchHubFilter.isScheduledFuture(match, now: now))
}
func testHubHidesYesterdayMatch() {
let now = iso("2026-09-06T08:30:00Z")
let match = hubMatch(scheduledAt: "2026-09-05T10:00:00Z")
XCTAssertFalse(MatchHubFilter.coachHubVisible(match, now: now))
}
func testHubRespectsApiCoachHubVisibleFalse() {
let now = iso("2026-09-06T08:30:00Z")
let match = hubMatch(scheduledAt: "2026-09-06T07:00:00Z", coachHubVisible: false)
XCTAssertFalse(MatchHubFilter.coachHubVisible(match, now: now))
}
private func iso(_ value: String) -> Date {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime]
return f.date(from: value)!
}
private func hubMatch(scheduledAt: String?, coachHubVisible: Bool? = nil) -> Match {
Match(
id: "match-hub",
teamId: "team-1",
teamName: "Team MLTV",
opponentName: "Team Guest",
location: nil,
scheduledAt: scheduledAt,
sportKey: "pallavolo",
sportLabel: "Pallavolo",
boardType: "volley",
overlayKind: "volley",
effectiveOverlayKind: "volley",
setsToWin: 3,
category: nil,
scoringRules: nil,
activeSessionId: nil,
activeSessionStatus: nil,
streamCompleted: false,
coachHubVisible: coachHubVisible,
homePrimaryColor: "#FF2D2D",
homeSecondaryColor: nil,
homeLogoUrl: nil,
opponentPrimaryColor: "#1E3A8A",
opponentLogoUrl: nil,
effectiveCoverUrl: nil,
coverSource: "default",
customCoverEnabled: false
)
}
}
final class ScoreboardOverlayTests: XCTestCase { final class ScoreboardOverlayTests: XCTestCase {
func testScoreboardColumnsAfterSetClosed() { func testScoreboardColumnsAfterSetClosed() {
let score = ScoreState( let score = ScoreState(
+4 -4
View File
@@ -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 = 35; CURRENT_PROJECT_VERSION = 38;
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.12; MARKETING_VERSION = 2.0.13;
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 = 35; CURRENT_PROJECT_VERSION = 38;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 16.0; IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MARKETING_VERSION = 2.0.12; MARKETING_VERSION = 2.0.13;
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;