Compare commits
14
Commits
097e55df79
...
produzione
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2db6541c91 | ||
|
|
5d4bd8544d | ||
|
|
cc95fcacbf | ||
|
|
d7dd744c32 | ||
|
|
adfea01cdb | ||
|
|
81e7e3c9af | ||
|
|
53ad26fdbe | ||
|
|
db0e842ca7 | ||
|
|
2e67e590d6 | ||
|
|
bb342ead79 | ||
|
|
ce81bb5789 | ||
|
|
24c6ce4da0 | ||
|
|
c3ef8e91a6 | ||
|
|
b09e83db09 |
@@ -6,7 +6,8 @@ module Admin
|
||||
@filters = {
|
||||
from: parse_date(params[:from]) || 7.days.ago.to_date,
|
||||
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?
|
||||
|
||||
@@ -36,6 +37,24 @@ module Admin
|
||||
max_scroll: max_scroll_by_path[path].to_i
|
||||
}
|
||||
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
|
||||
|
||||
def show
|
||||
|
||||
@@ -1,18 +1,34 @@
|
||||
module Admin
|
||||
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
|
||||
@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)
|
||||
end
|
||||
|
||||
def show
|
||||
@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)
|
||||
load_show_context
|
||||
end
|
||||
|
||||
def edit
|
||||
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
|
||||
|
||||
def grant_comped
|
||||
@@ -63,7 +79,76 @@ module Admin
|
||||
private
|
||||
|
||||
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
|
||||
|
||||
def redirect_back_or_club(notice: nil, alert: nil)
|
||||
|
||||
@@ -10,7 +10,7 @@ module Api
|
||||
|
||||
def index
|
||||
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)
|
||||
unless current_user.club_admin?(@team.club)
|
||||
if @team.tournament_broadcast?
|
||||
|
||||
@@ -16,7 +16,7 @@ module Public
|
||||
Tournaments::Entitlements.new(@club).assert_writable!
|
||||
match = @tournament.matches.find(params[:match_id])
|
||||
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)
|
||||
end
|
||||
match.update!(attrs)
|
||||
|
||||
@@ -38,19 +38,21 @@ module Public
|
||||
.includes(
|
||||
:tournament_group,
|
||||
:tournament_round,
|
||||
:stream_sessions,
|
||||
home_participant: { logo_file_attachment: :blob },
|
||||
away_participant: { logo_file_attachment: :blob }
|
||||
)
|
||||
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :asc)
|
||||
|
||||
@live_sessions = StreamSession
|
||||
broadcasting = StreamSession
|
||||
.broadcasting
|
||||
.publicly_listed
|
||||
.where(platform: "matchlivetv")
|
||||
.includes(:score_state, match: [:home_participant, :away_participant, { team: :club }])
|
||||
.where(match_id: @tournament.matches.select(:id))
|
||||
.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
|
||||
.joins(stream_session: :match)
|
||||
|
||||
@@ -67,7 +67,7 @@ module Public
|
||||
sessions = match.stream_sessions.to_a
|
||||
latest = sessions.max_by(&:created_at)
|
||||
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
|
||||
end
|
||||
@@ -100,12 +100,24 @@ module Public
|
||||
def tournament_public_board_state(match)
|
||||
return :live if tournament_public_live_session(match)
|
||||
return :replay if tournament_public_recording(match)
|
||||
return :ended if match.result_recorded?
|
||||
return :scheduled if match.scheduled_upcoming?
|
||||
return :ended if match.played? || match.home_score.present?
|
||||
|
||||
:waiting
|
||||
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)
|
||||
return "#{match.home_score}–#{match.away_score}" if match.home_score.present? && match.away_score.present?
|
||||
|
||||
@@ -117,7 +129,7 @@ module Public
|
||||
end
|
||||
|
||||
def tournament_match_sides_locked?(match)
|
||||
match.played? || match.result_status.to_s.start_with?("walkover")
|
||||
match.result_recorded?
|
||||
end
|
||||
|
||||
def tournament_phase_label(match)
|
||||
|
||||
@@ -21,7 +21,14 @@ class Club < ApplicationRecord
|
||||
validates :secondary_color, presence: true
|
||||
|
||||
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
|
||||
|
||||
def owned_by?(user)
|
||||
|
||||
@@ -59,18 +59,43 @@ module ClubBillingProfile
|
||||
def billing_profile_invoice_lines
|
||||
lines = []
|
||||
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 << ["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?
|
||||
addr = [billing_address_line, billing_postal_code, billing_city, billing_province, billing_country].compact.join(", ")
|
||||
lines << ["Indirizzo", addr] if addr.present?
|
||||
core_address = [billing_address_line, billing_postal_code, billing_city, billing_province].compact_blank
|
||||
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 << ["PEC", billing_pec] if billing_pec.present?
|
||||
lines
|
||||
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
|
||||
|
||||
def billing_profile_for_invoicing
|
||||
|
||||
@@ -94,14 +94,27 @@ class Match < ApplicationRecord
|
||||
opponent_primary_color.presence || DEFAULT_OPPONENT_COLOR
|
||||
end
|
||||
|
||||
def result_recorded?
|
||||
played? || result_status.to_s.start_with?("walkover") || (home_score.present? && away_score.present?)
|
||||
end
|
||||
|
||||
def coach_hub_visible?
|
||||
active = active_stream_session
|
||||
return true if active&.resumable?
|
||||
return true if active&.idle?
|
||||
|
||||
return false if result_recorded?
|
||||
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
|
||||
|
||||
def effective_board_type
|
||||
|
||||
@@ -146,14 +146,22 @@ class Recording < ApplicationRecord
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
def default_title
|
||||
match = stream_session&.match
|
||||
return "Replay" unless match
|
||||
|
||||
"#{match.team.name} vs #{match.opponent_name}"
|
||||
match.matchup_label
|
||||
end
|
||||
|
||||
def recorded_at_or_fallback
|
||||
@@ -222,6 +230,18 @@ class Recording < ApplicationRecord
|
||||
|
||||
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
|
||||
self.privacy_status = "public" if privacy_status == "private"
|
||||
self.privacy_status = "unlisted" if privacy_status.blank?
|
||||
|
||||
@@ -15,7 +15,7 @@ class StreamSession < ApplicationRecord
|
||||
has_many :attempted_concurrency_violations, class_name: "StreamConcurrencyViolation",
|
||||
foreign_key: :attempted_session_id, dependent: :nullify, inverse_of: :attempted_session
|
||||
has_one :score_state, dependent: :destroy
|
||||
has_one :recording
|
||||
has_one :recording, dependent: :destroy
|
||||
has_many :device_states, dependent: :destroy
|
||||
|
||||
validates :platform, inclusion: { in: PLATFORMS }
|
||||
@@ -186,6 +186,12 @@ class StreamSession < ApplicationRecord
|
||||
platform == "youtube" && youtube_watch_url.present?
|
||||
end
|
||||
|
||||
def public_watchable?
|
||||
return true if matchlivetv_platform? && publicly_listed?
|
||||
|
||||
youtube_ready?
|
||||
end
|
||||
|
||||
def link_only?
|
||||
privacy_status.in?(%w[unlisted private])
|
||||
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
|
||||
match = @session.match
|
||||
"#{match.team.name} vs #{match.opponent_name}"
|
||||
match.matchup_label.presence || "Replay"
|
||||
end
|
||||
|
||||
def privacy_from_session
|
||||
|
||||
@@ -50,6 +50,14 @@
|
||||
@filters[:device]
|
||||
) %>
|
||||
</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 class="admin-filter-actions">
|
||||
<%= submit_tag t("admin.analytics.filters.apply"), class: "admin-btn admin-btn--primary admin-btn--sm" %>
|
||||
@@ -58,8 +66,66 @@
|
||||
<% end %>
|
||||
</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">
|
||||
<% 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">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
@@ -76,14 +142,18 @@
|
||||
<tbody>
|
||||
<% @pages.each do |row| %>
|
||||
<% 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><%= row[:pageviews] %></td>
|
||||
<td><%= row[:moves] %></td>
|
||||
<td><%= row[:clicks] %></td>
|
||||
<td class="muted"><%= avg %>%</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[:device] = @filters[:device] if @filters[:device].present? %>
|
||||
<%= 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>
|
||||
@@ -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.plan") %></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.stripe") %></th>
|
||||
<th><%= t("admin.clubs.index.table.quote") %></th>
|
||||
@@ -18,10 +19,16 @@
|
||||
<tbody>
|
||||
<% @clubs.each do |club| %>
|
||||
<% sub = club.subscription %>
|
||||
<% billing_status = club.billing_profile_admin_status %>
|
||||
<tr>
|
||||
<td><strong><%= club.name %></strong></td>
|
||||
<td><%= sub&.plan&.name || t("admin.common.free_plan") %></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>
|
||||
<% if sub&.admin_comped? %>
|
||||
<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.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.edit_link"), edit_admin_club_path(@club) %>
|
||||
</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/quote_form", club: @club, quote: @quote, return_to: admin_club_path(@club) %>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<%= csrf_meta_tags %>
|
||||
<link rel="stylesheet" href="/admin.css?v=16">
|
||||
<link rel="stylesheet" href="/admin.css?v=21">
|
||||
<%= yield :head %>
|
||||
<% if content_for?(:replay_archive_styles) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<%= render "shared/analytics_suppress" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=101">
|
||||
<link rel="stylesheet" href="/marketing.css?v=102">
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
@@ -24,7 +24,7 @@
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_footer_app_store" : "shared/marketing_footer") %>
|
||||
<script src="/branding-form.js?v=1" 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="/password-toggle.js?v=2" defer></script>
|
||||
<link rel="stylesheet" href="/confirm-forms.css?v=4">
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= render "shared/analytics_suppress" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=101">
|
||||
<link rel="stylesheet" href="/marketing.css?v=102">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
|
||||
@@ -37,8 +37,12 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="tournament-board__actions">
|
||||
<% if live_session %>
|
||||
<%= link_to t("tournaments.page.watch_live"), public_live_path(live_session), class: "tournament-board__cta tournament-board__cta--live" %>
|
||||
<% watch = tournament_public_watch_target(live_session) %>
|
||||
<% 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 %>
|
||||
<%= link_to t("tournaments.page.watch_replay"), public_replay_path(recording.stream_session_id), class: "tournament-board__cta" %>
|
||||
<% else %>
|
||||
|
||||
@@ -52,8 +52,11 @@
|
||||
<span class="replay-card__play" aria-hidden="true">▶</span>
|
||||
</div>
|
||||
<div class="replay-card__body">
|
||||
<strong class="replay-card__title"><%= rec.title.presence || match.matchup_label %></strong>
|
||||
<p class="replay-card__meta"><%= match.matchup_label %></p>
|
||||
<% phase = [match.tournament_round&.name, match.tournament_group&.name].compact.first %>
|
||||
<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>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
@@ -64,7 +64,11 @@
|
||||
<span class="badge badge-live"><%= t("live.index.badge_live") %></span>
|
||||
<% end %>
|
||||
</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>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -8,88 +8,105 @@
|
||||
|
||||
<% if @matches.any? %>
|
||||
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.calendar_edit_hint") %></p>
|
||||
<div class="tournament-table-wrap">
|
||||
<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| %>
|
||||
<% form_id = "match-edit-#{match.id}" %>
|
||||
<% 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 %>
|
||||
<%= hidden_field_tag :tab, @tab, form: form_id %>
|
||||
<% end %>
|
||||
<tr>
|
||||
<% if @writable %>
|
||||
<td>
|
||||
<%= 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" %>
|
||||
</td>
|
||||
<td>
|
||||
<%= 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" %>
|
||||
</td>
|
||||
<td><%= tournament_phase_label(match) %></td>
|
||||
<td data-swap-side="home"><%= render "public/tournaments/match_side_select", match: match, side: :home, form_id: form_id %></td>
|
||||
<td class="tournament-match-swap-col">
|
||||
<%= button_to public_swap_tournament_match_path(@tournament, match), method: :post,
|
||||
class: "tournament-match-swap__btn",
|
||||
title: t("tournaments.hub.swap_sides"),
|
||||
form: { class: "tournament-match-swap__form", data: { turbo: false } } do %>
|
||||
<i class="fa-solid fa-right-left" aria-hidden="true"></i>
|
||||
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></td>
|
||||
<td>
|
||||
<% if match.played? || match.result_status.to_s.start_with?("walkover") %>
|
||||
<span data-swap-played-score><%= match.home_score %>–<%= match.away_score %></span>
|
||||
<% else %>
|
||||
<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" } %>
|
||||
<span>–</span>
|
||||
<%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %>
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td class="tournament-match-form__actions">
|
||||
<%= submit_tag t("tournaments.hub.save_match"), form: form_id, class: "btn btn-secondary tournament-match-form__save" %>
|
||||
<% if match.deletable? %>
|
||||
<%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match),
|
||||
method: :delete, class: "btn btn-secondary tournament-match-form__save",
|
||||
form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% else %>
|
||||
<td><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></td>
|
||||
<td><%= match.court || "—" %></td>
|
||||
<td><%= tournament_phase_label(match) %></td>
|
||||
<td><%= tournament_team_chip(match.home_participant, name: match.home_display_name) %></td>
|
||||
<td class="tournament-match-swap-col"></td>
|
||||
<td><%= tournament_team_chip(match.away_participant, name: match.away_display_name) %></td>
|
||||
<td>
|
||||
<div class="tournament-match-list tournament-match-table" data-swap-error="<%= t("flash.tournaments.sides_swap_failed") %>">
|
||||
<% @matches.each do |match| %>
|
||||
<% form_id = "match-edit-#{match.id}" %>
|
||||
<% 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 %>
|
||||
<%= hidden_field_tag :tab, @tab, form: form_id %>
|
||||
<% end %>
|
||||
<article class="tournament-match-row">
|
||||
<% if @writable %>
|
||||
<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),
|
||||
id: "match_at_#{match.id}", form: form_id, required: true, class: "tournament-match-form__datetime" %>
|
||||
</div>
|
||||
<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),
|
||||
id: "match_court_#{match.id}", form: form_id, include_blank: true, class: "tournament-match-form__court" %>
|
||||
</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>
|
||||
<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,
|
||||
class: "tournament-match-swap__btn",
|
||||
title: t("tournaments.hub.swap_sides"),
|
||||
form: { class: "tournament-match-swap__form", data: { turbo: false } } do %>
|
||||
<i class="fa-solid fa-right-left" aria-hidden="true"></i>
|
||||
<span class="visually-hidden"><%= t("tournaments.hub.swap_sides") %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
<div class="tournament-match-row__side">
|
||||
<span class="tournament-match-row__label"><%= t("tournaments.hub.away") %></span>
|
||||
<div data-swap-side="away"><%= render "public/tournaments/match_side_select", match: match, side: :away, form_id: form_id %></div>
|
||||
</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 %>
|
||||
<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" } %>
|
||||
<span>–</span>
|
||||
<%= number_field_tag :away_score, match.away_score, id: "away_score_#{match.id}", form: form_id, data: { swap_score: "away" } %>
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
</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" %>
|
||||
<% if match.deletable? %>
|
||||
<%= button_to t("matches.index.delete"), public_tournament_match_path(@tournament, match),
|
||||
method: :delete, class: "btn btn-secondary tournament-match-form__save",
|
||||
form: { class: "tournament-match-delete-form", data: { turbo_confirm: match.matchup_label } } %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="tournament-match-row__field">
|
||||
<span class="tournament-match-row__label"><%= t("tournaments.hub.datetime") %></span>
|
||||
<p class="tournament-match-row__phase"><%= match.scheduled_at ? l(match.scheduled_at, format: :short) : "—" %></p>
|
||||
</div>
|
||||
<div class="tournament-match-row__field">
|
||||
<span class="tournament-match-row__label"><%= t("tournaments.hub.court") %></span>
|
||||
<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? %>
|
||||
<%= match.home_score %>–<%= match.away_score %>
|
||||
<% else %>
|
||||
—
|
||||
<% end %>
|
||||
</td>
|
||||
<td></td>
|
||||
<% end %>
|
||||
</tr>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="tournament-hub-hint" style="margin-top:16px"><%= t("tournaments.hub.no_matches") %></p>
|
||||
|
||||
@@ -48,6 +48,7 @@ de:
|
||||
invoice_issued: "Rechnung %{number} ausgestellt und an %{email} gesendet."
|
||||
invoice_updated: "Rechnung %{number} aktualisiert."
|
||||
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
|
||||
announcement_created: Hinweis gespeichert
|
||||
announcement_updated: Hinweis aktualisiert
|
||||
@@ -239,8 +240,10 @@ de:
|
||||
club: Verein
|
||||
plan: Plan
|
||||
teams: Teams
|
||||
billing_profile: Rechnungsdaten
|
||||
comped: Kostenlos
|
||||
stripe: Stripe
|
||||
quote: Vereinbart
|
||||
manage: Verwalten
|
||||
invoices: Rechnungen
|
||||
show:
|
||||
@@ -254,6 +257,13 @@ de:
|
||||
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}."
|
||||
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
|
||||
no_teams: Keine Teams registriert.
|
||||
table:
|
||||
@@ -265,6 +275,46 @@ de:
|
||||
concurrency_lead: Dasselbe Konto hat versucht, eine weitere Direktübertragung zu starten, während bereits eine lief.
|
||||
concurrency_none: Keine Versuche für diesen Verein erfasst.
|
||||
concurrency_all: Alle Konto-Missbräuche ansehen
|
||||
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:
|
||||
title: Kostenloses Abonnement
|
||||
description: "Sponsor oder Aktion: Vergib Premium Light/Full ohne Stripe-Zahlung. Jederzeit widerrufbar."
|
||||
@@ -415,6 +465,8 @@ de:
|
||||
any: Alle
|
||||
apply: Filtern
|
||||
reset: Zurücksetzen
|
||||
chart_path: Seite (Diagramm)
|
||||
chart_path_all: Alle Seiten
|
||||
layer: Ebene
|
||||
layer_move: Mausbewegungen
|
||||
layer_click: Klicks
|
||||
@@ -427,6 +479,17 @@ de:
|
||||
lead: Aggregierte First-Party-Heatmaps (Bewegung/Klick) und Scrolltiefe, nur mit Statistik-Einwilligung. Keine personenbezogenen Daten.
|
||||
none: Keine Daten im gewählten Zeitraum.
|
||||
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:
|
||||
path: Seite
|
||||
pageviews: Pageviews
|
||||
|
||||
@@ -48,6 +48,7 @@ en:
|
||||
invoice_issued: "Invoice %{number} issued and sent to %{email}."
|
||||
invoice_updated: "Invoice %{number} updated."
|
||||
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
|
||||
announcement_created: Notice saved
|
||||
announcement_updated: Notice updated
|
||||
@@ -239,8 +240,10 @@ en:
|
||||
club: Club
|
||||
plan: Plan
|
||||
teams: Teams
|
||||
billing_profile: Billing data
|
||||
comped: Comp
|
||||
stripe: Stripe
|
||||
quote: Quoted
|
||||
manage: Manage
|
||||
invoices: Invoices
|
||||
show:
|
||||
@@ -254,6 +257,13 @@ en:
|
||||
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}."
|
||||
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
|
||||
no_teams: No teams registered.
|
||||
table:
|
||||
@@ -265,6 +275,46 @@ en:
|
||||
concurrency_lead: Same account tried to start another live while one was already running.
|
||||
concurrency_none: No attempts recorded for this club.
|
||||
concurrency_all: View all account abuse
|
||||
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:
|
||||
title: Complimentary subscription
|
||||
description: "Sponsor or promotion: grant Premium Light/Full without a Stripe payment. Revocable at any time."
|
||||
@@ -415,6 +465,8 @@ en:
|
||||
any: All
|
||||
apply: Filter
|
||||
reset: Reset
|
||||
chart_path: Page (chart)
|
||||
chart_path_all: All pages
|
||||
layer: Layer
|
||||
layer_move: Mouse moves
|
||||
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.
|
||||
none: No data in the selected period.
|
||||
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:
|
||||
path: Page
|
||||
pageviews: Pageviews
|
||||
|
||||
@@ -48,6 +48,7 @@ es:
|
||||
invoice_issued: "Factura %{number} emitida y enviada a %{email}."
|
||||
invoice_updated: "Factura %{number} actualizada."
|
||||
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
|
||||
announcement_created: Aviso guardado
|
||||
announcement_updated: Aviso actualizado
|
||||
@@ -239,8 +240,10 @@ es:
|
||||
club: Club
|
||||
plan: Plan
|
||||
teams: Equipos
|
||||
billing_profile: Datos fiscales
|
||||
comped: Cortesía
|
||||
stripe: Stripe
|
||||
quote: Acordado
|
||||
manage: Gestionar
|
||||
invoices: Facturas
|
||||
show:
|
||||
@@ -254,6 +257,13 @@ es:
|
||||
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}."
|
||||
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
|
||||
no_teams: No hay equipos registrados.
|
||||
table:
|
||||
@@ -265,6 +275,46 @@ es:
|
||||
concurrency_lead: La misma cuenta intentó iniciar otro directo mientras ya había uno en curso.
|
||||
concurrency_none: No hay intentos registrados para este club.
|
||||
concurrency_all: Ver todos los abusos de cuenta
|
||||
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:
|
||||
title: Suscripción de cortesía
|
||||
description: "Patrocinador o promoción: concede Premium Light/Full sin pago en Stripe. Revocable en cualquier momento."
|
||||
@@ -415,6 +465,8 @@ es:
|
||||
any: Todos
|
||||
apply: Filtrar
|
||||
reset: Restablecer
|
||||
chart_path: Página (gráfico)
|
||||
chart_path_all: Todas las páginas
|
||||
layer: Capa
|
||||
layer_move: Movimientos del ratón
|
||||
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.
|
||||
none: No hay datos en el periodo seleccionado.
|
||||
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:
|
||||
path: Página
|
||||
pageviews: Pageviews
|
||||
|
||||
@@ -48,6 +48,7 @@ fr:
|
||||
invoice_issued: "Facture %{number} émise et envoyée à %{email}."
|
||||
invoice_updated: "Facture %{number} mise à jour."
|
||||
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
|
||||
announcement_created: Alerte enregistrée
|
||||
announcement_updated: Alerte mise à jour
|
||||
@@ -239,8 +240,10 @@ fr:
|
||||
club: Club
|
||||
plan: Forfait
|
||||
teams: Équipes
|
||||
billing_profile: Données fiscales
|
||||
comped: Offert
|
||||
stripe: Stripe
|
||||
quote: Convenu
|
||||
manage: Gérer
|
||||
invoices: Factures
|
||||
show:
|
||||
@@ -254,6 +257,13 @@ fr:
|
||||
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}."
|
||||
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
|
||||
no_teams: Aucune équipe enregistrée.
|
||||
table:
|
||||
@@ -265,6 +275,46 @@ fr:
|
||||
concurrency_lead: Le même compte a tenté de démarrer un autre direct alors qu’un était déjà en cours.
|
||||
concurrency_none: Aucune tentative enregistrée pour ce club.
|
||||
concurrency_all: Voir tous les abus de compte
|
||||
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:
|
||||
title: Abonnement offert
|
||||
description: "Sponsor ou promotion : accordez Premium Light/Full sans paiement Stripe. Révocable à tout moment."
|
||||
@@ -415,6 +465,8 @@ fr:
|
||||
any: Tous
|
||||
apply: Filtrer
|
||||
reset: Réinitialiser
|
||||
chart_path: Page (graphique)
|
||||
chart_path_all: Toutes les pages
|
||||
layer: Couche
|
||||
layer_move: Mouvements souris
|
||||
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.
|
||||
none: Aucune donnée sur la période sélectionnée.
|
||||
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 d’une page.
|
||||
table:
|
||||
path: Page
|
||||
pageviews: Pages vues
|
||||
|
||||
@@ -52,6 +52,7 @@ it:
|
||||
quote_revoked: "Prezzo concordato revocato per %{club}."
|
||||
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
|
||||
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
|
||||
announcement_created: Avviso salvato
|
||||
announcement_updated: Avviso aggiornato
|
||||
@@ -243,6 +244,7 @@ it:
|
||||
club: Società
|
||||
plan: Piano
|
||||
teams: Squadre
|
||||
billing_profile: Dati fiscali
|
||||
comped: Omaggio
|
||||
stripe: Stripe
|
||||
quote: Concordato
|
||||
@@ -259,6 +261,13 @@ it:
|
||||
youtube_premium_full_not_connected: Premium Full — canale società non collegato (usa Match Live TV)
|
||||
no_channel_hint_html: "Senza canale collegato, l’app usa il canale Match Live TV. %{link}."
|
||||
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
|
||||
no_teams: Nessuna squadra registrata.
|
||||
table:
|
||||
@@ -270,6 +279,46 @@ it:
|
||||
concurrency_lead: Stesso account che ha provato ad avviare un’altra diretta mentre ne era già in corso una.
|
||||
concurrency_none: Nessun tentativo registrato per questa società.
|
||||
concurrency_all: Vedi tutti gli abusi account
|
||||
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:
|
||||
title: Abbonamento omaggio
|
||||
description: "Sponsor o promozione: assegna Premium Light/Full senza pagamento Stripe. Revocabile in qualsiasi momento."
|
||||
@@ -436,6 +485,8 @@ it:
|
||||
any: Tutti
|
||||
apply: Filtra
|
||||
reset: Azzera
|
||||
chart_path: Pagina (grafico)
|
||||
chart_path_all: Tutte le pagine
|
||||
layer: Livello
|
||||
layer_move: Movimenti mouse
|
||||
layer_click: Click
|
||||
@@ -448,6 +499,17 @@ it:
|
||||
lead: Heatmap movimenti/click e scroll aggregati (first-party), solo con consenso statistico. Nessun dato personale.
|
||||
none: Nessun dato nel periodo selezionato.
|
||||
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 l’andamento di una sola pagina.
|
||||
table:
|
||||
path: Pagina
|
||||
pageviews: Pageview
|
||||
|
||||
@@ -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/cancel", to: "billing#cancel_transfer", as: :billing_transfer_cancel
|
||||
resources :teams, only: %i[show]
|
||||
resources :clubs, only: %i[index show] do
|
||||
resources :clubs, only: %i[index show edit update] do
|
||||
member do
|
||||
post :grant_comped
|
||||
delete :revoke_comped
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -173,6 +173,44 @@ body.admin-body {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
@@ -584,6 +622,35 @@ body.admin-body {
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -614,6 +681,19 @@ body.admin-body {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
|
||||
@@ -1183,6 +1183,93 @@ body.invite-editor--resizing {
|
||||
|
||||
.tournament-hub-hint { color: #888; margin: 0 0 12px; }
|
||||
.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 {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1205,19 +1292,19 @@ body.invite-editor--resizing {
|
||||
background: #12121a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tournament-match-table .tournament-match-form__datetime {
|
||||
table.tournament-match-table .tournament-match-form__datetime {
|
||||
min-width: 190px;
|
||||
margin-bottom: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tournament-match-table .tournament-match-form__court {
|
||||
table.tournament-match-table .tournament-match-form__court {
|
||||
min-width: 120px;
|
||||
margin-bottom: 0;
|
||||
padding: 8px 10px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.tournament-match-table .tournament-match-form__team {
|
||||
table.tournament-match-table .tournament-match-form__team {
|
||||
min-width: 160px;
|
||||
max-width: 220px;
|
||||
margin-bottom: 0;
|
||||
@@ -1260,6 +1347,8 @@ body.invite-editor--resizing {
|
||||
padding: 4px 10px;
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tournament-match-swap-col {
|
||||
width: 44px;
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
event.stopImmediatePropagation();
|
||||
if (form.dataset.swapBusy === "1") return;
|
||||
|
||||
var row = form.closest("tr");
|
||||
var row = form.closest(".tournament-match-row") || form.closest("tr");
|
||||
if (!row) return;
|
||||
|
||||
form.dataset.swapBusy = "1";
|
||||
|
||||
@@ -41,4 +41,15 @@ RSpec.describe ClubBillingProfile do
|
||||
club.billing_fiscal_code = "RSSMRA80A01H501U"
|
||||
expect(club.billing_profile_complete?).to be true
|
||||
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
|
||||
|
||||
@@ -83,15 +83,93 @@ RSpec.describe Match, "scheduling scopes" do
|
||||
expect(match.coach_hub_visible?).to be(false)
|
||||
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!(
|
||||
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,
|
||||
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)
|
||||
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
|
||||
|
||||
describe "scoring_rules" do
|
||||
|
||||
@@ -31,4 +31,45 @@ RSpec.describe Recording do
|
||||
rec = described_class.create!(stream_session: session, team: team, status: "processing")
|
||||
expect(rec.title_or_default).to eq("Team vs Rival")
|
||||
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
|
||||
|
||||
@@ -7,6 +7,8 @@ RSpec.describe "Admin analytics", type: :request do
|
||||
|
||||
before do
|
||||
post admin_login_path, params: { username: admin.username, password: "Password123" }
|
||||
AnalyticsPageStat.delete_all
|
||||
AnalyticsPageCell.delete_all
|
||||
AnalyticsPageStat.create!(
|
||||
day: Time.zone.today,
|
||||
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("12")
|
||||
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
|
||||
|
||||
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
|
||||
get admin_analytics_page_path, params: { page_path: "/prezzi" }
|
||||
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")
|
||||
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("tournament-calendar.js")
|
||||
|
||||
@@ -767,7 +768,7 @@ RSpec.describe "Tournaments", type: :request do
|
||||
session.update_columns(status: "ended", ended_at: Time.current)
|
||||
Recording.create!(
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -775,9 +776,89 @@ RSpec.describe "Tournaments", type: :request do
|
||||
expect(response.body).to include("Guarda replay")
|
||||
expect(response.body).to include(public_replay_path(session.id))
|
||||
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)
|
||||
expect(response.body).to include(public_tournament_page_path(tournament.slug))
|
||||
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("2–0")
|
||||
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
|
||||
|
||||
@@ -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.expires_at).to be > 29.days.from_now
|
||||
expect(rec.metadata["auto_publish_youtube"]).to eq(false)
|
||||
expect(rec.title).to eq("Team vs Rival")
|
||||
end
|
||||
|
||||
it "skips free plan" do
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
BIN
Binary file not shown.
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
+1
@@ -0,0 +1 @@
|
||||
fake mp4
|
||||
@@ -24,8 +24,8 @@ android {
|
||||
applicationId = "com.matchlivetv.match_live_tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 33
|
||||
versionName = "2.0.12-native"
|
||||
versionCode = 34
|
||||
versionName = "2.0.13-native"
|
||||
|
||||
val apiBaseUrl = project.findProperty("API_BASE_URL") as String?
|
||||
?: "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.isScheduledOnCalendar(
|
||||
now: Instant = Instant.now(),
|
||||
zone: ZoneId = ZoneId.of("Europe/Rome"),
|
||||
): Boolean {
|
||||
val startOfToday = now.atZone(zone).toLocalDate().atStartOfDay(zone).toInstant()
|
||||
return !isBefore(startOfToday)
|
||||
}
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
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 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 {
|
||||
coachHubVisible?.let { return it }
|
||||
|
||||
@@ -15,7 +15,7 @@ fun Match.isCoachHubVisible(now: Instant = Instant.now()): Boolean {
|
||||
if (streamCompleted) return false
|
||||
|
||||
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> =
|
||||
|
||||
+13
-9
@@ -114,34 +114,38 @@ class CompactScoreboardElement : OverlayElement {
|
||||
val accentColor = if (isHome) compact.homeAccentColor else compact.awayAccentColor
|
||||
val logoUrl = if (isHome) compact.homeLogoUrl else compact.awayLogoUrl
|
||||
val logo = OverlayLogoCache.get(logoUrl)
|
||||
val rawName = if (isHome) compact.homeTeamName else compact.awayTeamName
|
||||
val name = ellipsize(rawName.trim(), labelPaint, maxWidth * 0.55f)
|
||||
val rawName = (if (isHome) compact.homeTeamName else compact.awayTeamName).trim()
|
||||
val barHalfH = logoSize * 0.45f
|
||||
val textY = centerY + labelPaint.textSize * 0.35f
|
||||
|
||||
if (isHome) {
|
||||
var x = rectLeft
|
||||
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
|
||||
if (logo != null) {
|
||||
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
|
||||
canvas.drawBitmap(logo, null, dest, logoPaint)
|
||||
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 {
|
||||
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
|
||||
var x = rectRight
|
||||
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
|
||||
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) {
|
||||
x -= logoSize
|
||||
val dest = RectF(x, centerY - logoSize / 2f, x + logoSize, centerY + logoSize / 2f)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-3
@@ -47,8 +47,6 @@ import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.repository.MatchSessionLauncher
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
@@ -161,7 +159,7 @@ fun MatchesScreen(
|
||||
(match.hasActiveSession && match.activeSessionStatus == "idle")
|
||||
}
|
||||
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 calendarMatches = (scheduledMatches + draftMatches).distinctBy { it.id }
|
||||
|
||||
+16
-1
@@ -10,7 +10,22 @@ class ApiInstantTest {
|
||||
fun parseApiInstant_railsOffsetWithMillis() {
|
||||
val instant = parseApiInstant("2026-06-06T20:00:00.000+02:00")
|
||||
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
|
||||
|
||||
@@ -677,11 +677,11 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -698,7 +698,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
DEVELOPMENT_TEAM = S8Q9TWBRG5;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||
@@ -709,7 +709,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.12;
|
||||
MARKETING_VERSION = 2.0.13;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -727,7 +727,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
DEVELOPMENT_TEAM = S8Q9TWBRG5;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
@@ -739,7 +739,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.12;
|
||||
MARKETING_VERSION = 2.0.13;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.matchlivetv.match-live-tv";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -756,11 +756,11 @@
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -775,11 +775,11 @@
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -794,11 +794,11 @@
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_KEY_API_BASE_URL = "$(API_BASE_URL)";
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
@@ -19,6 +19,14 @@ enum ApiInstant {
|
||||
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? {
|
||||
guard let date = parse(raw) else { return nil }
|
||||
let f = DateFormatter()
|
||||
|
||||
@@ -12,13 +12,17 @@ enum MatchHubFilter {
|
||||
}
|
||||
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 {
|
||||
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 {
|
||||
!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 |
@@ -19,7 +19,7 @@
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>2.0.12</string>
|
||||
<string>2.0.13</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -32,7 +32,7 @@
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>35</string>
|
||||
<string>38</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
|
||||
@@ -67,32 +67,37 @@ struct CompactScoreboardElement: OverlayElement {
|
||||
let accent = isHome ? compact.homeAccentColor : compact.awayAccentColor
|
||||
let logoUrl = isHome ? compact.homeLogoUrl : compact.awayLogoUrl
|
||||
let logo = OverlayLogoCache.get(logoUrl)
|
||||
let rawName = isHome ? compact.homeTeamName : compact.awayTeamName
|
||||
let name = ellipsize(rawName.trimmingCharacters(in: .whitespacesAndNewlines), font: labelFont, maxWidth: maxWidth * 0.55)
|
||||
let rawName = (isHome ? compact.homeTeamName : compact.awayTeamName)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
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 {
|
||||
var x = rectLeft
|
||||
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)
|
||||
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)))
|
||||
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 {
|
||||
// Specchio del lato casa: barra, logo, poi nome a sinistra del logo.
|
||||
var x = rectRight
|
||||
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)
|
||||
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 {
|
||||
x -= 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] {
|
||||
matches.filter { !$0.hasActiveSession && MatchHubFilter.isScheduledFuture($0) }
|
||||
matches.filter { !$0.hasActiveSession && !($0.scheduledAt ?? "").isEmpty }
|
||||
}
|
||||
|
||||
private var calendarMatches: [Match] {
|
||||
|
||||
@@ -1,7 +1,45 @@
|
||||
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 {
|
||||
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 un’altra.","error_code":"user_concurrent_stream"}
|
||||
"""
|
||||
XCTAssertEqual(
|
||||
APIError.http(403, body).errorDescription,
|
||||
"Hai già una diretta in corso con questo account. Chiudila prima di avviarne un’altra."
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
XCTAssertEqual(OverlayKindMapping.fromApi("basket"), "basket")
|
||||
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 {
|
||||
func testScoreboardColumnsAfterSetClosed() {
|
||||
let score = ScoreState(
|
||||
|
||||
@@ -96,7 +96,7 @@ lines += [
|
||||
app_settings = """
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
GENERATE_INFOPLIST_FILE = NO;
|
||||
INFOPLIST_FILE = MatchLiveTv/Resources/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
@@ -107,7 +107,7 @@ app_settings = """
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.0.12;
|
||||
MARKETING_VERSION = 2.0.13;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.matchlivetv.match-live-tv;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
API_BASE_URL = "https://www.matchlivetv.it";
|
||||
@@ -122,10 +122,10 @@ app_debug_settings = app_settings + """
|
||||
test_settings = """
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 35;
|
||||
CURRENT_PROJECT_VERSION = 38;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
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_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
|
||||
Reference in New Issue
Block a user