Billing Stripe, link regia mobile e staff solo trasmissione.
Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
module ApplicationCable
|
||||
class Connection < ActionCable::Connection::Base
|
||||
identified_by :current_user
|
||||
identified_by :current_user, :regia_session
|
||||
|
||||
def connect
|
||||
if (regia_token = request.params[:regia_token].presence)
|
||||
self.regia_session = Sessions::RegiaAccess.find_session_by_token(regia_token)
|
||||
reject_unauthorized_connection unless regia_session
|
||||
return
|
||||
end
|
||||
|
||||
payload = JsonWebToken.decode(token_from_header)
|
||||
self.current_user = User.find_by(id: payload[:user_id]) if payload
|
||||
reject_unauthorized_connection unless current_user
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
class SessionChannel < ApplicationCable::Channel
|
||||
include CableBroadcastable
|
||||
def subscribed
|
||||
session = StreamSession.joins(match: :team)
|
||||
.merge(current_user.manageable_teams)
|
||||
.find(params[:session_id])
|
||||
session = resolve_session
|
||||
reject unless session && session.id.to_s == params[:session_id].to_s
|
||||
|
||||
stream_for session
|
||||
session.device_states.find_or_create_by!(device_role: params[:device_role] || "controller")
|
||||
end
|
||||
|
||||
def resolve_session
|
||||
if connection.regia_session
|
||||
connection.regia_session
|
||||
else
|
||||
StreamSession.joins(match: :team)
|
||||
.merge(connection.current_user.manageable_teams)
|
||||
.find_by(id: params[:session_id])
|
||||
end
|
||||
end
|
||||
|
||||
def unsubscribed
|
||||
stop_all_streams
|
||||
end
|
||||
|
||||
def receive(data)
|
||||
session = StreamSession.find(params[:session_id])
|
||||
session = resolve_session
|
||||
return unless session
|
||||
|
||||
handle_message(session, data)
|
||||
end
|
||||
|
||||
|
||||
50
backend/app/controllers/admin/billing_invoices_controller.rb
Normal file
50
backend/app/controllers/admin/billing_invoices_controller.rb
Normal file
@@ -0,0 +1,50 @@
|
||||
module Admin
|
||||
class BillingInvoicesController < BaseController
|
||||
before_action :set_club
|
||||
|
||||
def index
|
||||
@invoices = @club.billing_invoices.recent
|
||||
end
|
||||
|
||||
def new
|
||||
@invoice = @club.billing_invoices.build(issued_on: Date.current, currency: "eur", source: "aruba")
|
||||
end
|
||||
|
||||
def create
|
||||
@invoice = @club.billing_invoices.build(invoice_params)
|
||||
@invoice.source = "aruba"
|
||||
|
||||
if @invoice.save
|
||||
redirect_to admin_club_billing_invoices_path(@club), notice: "Fattura #{@invoice.number} registrata."
|
||||
else
|
||||
flash.now[:alert] = @invoice.errors.full_messages.join(", ")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_club
|
||||
@club = Club.find(params[:club_id])
|
||||
end
|
||||
|
||||
def invoice_params
|
||||
params.require(:billing_invoice).permit(
|
||||
:number,
|
||||
:issued_on,
|
||||
:amount_cents,
|
||||
:amount_euros,
|
||||
:currency,
|
||||
:status,
|
||||
:aruba_document_id,
|
||||
:billing_payment_id,
|
||||
:notes,
|
||||
:pdf
|
||||
).tap do |p|
|
||||
if p[:amount_euros].present?
|
||||
p[:amount_cents] = (p.delete(:amount_euros).to_f * 100).round
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,5 +8,19 @@ module Admin
|
||||
@session = StreamSession.find(params[:id])
|
||||
@events = @session.stream_events.recent.limit(50)
|
||||
end
|
||||
|
||||
def stop
|
||||
@session = StreamSession.find(params[:id])
|
||||
if @session.terminal?
|
||||
redirect_to admin_session_path(@session), alert: "Sessione già terminata (#{@session.status})."
|
||||
return
|
||||
end
|
||||
|
||||
Sessions::Stop.new(@session).call
|
||||
redirect_to admin_session_path(@session), notice: "Sessione terminata."
|
||||
rescue StandardError => e
|
||||
Rails.logger.error("[admin] stop session #{@session.id}: #{e.class} #{e.message}")
|
||||
redirect_to admin_session_path(@session), alert: "Errore durante la chiusura: #{e.message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -40,11 +40,13 @@ module Api
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_user.manageable_teams.find(params[:team_id])
|
||||
@team = current_user.streamable_teams.find { |t| t.id == params[:team_id] }
|
||||
raise ActiveRecord::RecordNotFound unless @team
|
||||
end
|
||||
|
||||
def set_match
|
||||
@match = Match.joins(:team).merge(current_user.manageable_teams).find(params[:id])
|
||||
team_ids = current_user.streamable_teams.map(&:id)
|
||||
@match = Match.where(team_id: team_ids).find(params[:id])
|
||||
end
|
||||
|
||||
def match_params
|
||||
|
||||
@@ -4,7 +4,8 @@ module Api
|
||||
before_action :set_session, except: :create
|
||||
|
||||
def create
|
||||
match = Match.joins(:team).merge(current_user.manageable_teams).find(params[:match_id])
|
||||
team_ids = current_user.streamable_teams.map(&:id)
|
||||
match = Match.where(team_id: team_ids).find(params[:match_id])
|
||||
session = Sessions::Create.new(user: current_user, match: match, params: session_params).call
|
||||
render json: session_json(session), status: :created
|
||||
end
|
||||
@@ -66,6 +67,15 @@ module Api
|
||||
}
|
||||
end
|
||||
|
||||
def regia_link
|
||||
access = Sessions::RegiaAccess.new(@session)
|
||||
token = access.issue_token!
|
||||
render json: {
|
||||
regia_url: access.url(token),
|
||||
expires_at: @session.regia_token_expires_at
|
||||
}
|
||||
end
|
||||
|
||||
def claim_pairing
|
||||
token = params.require(:pairing_token)
|
||||
digest = Digest::SHA256.hexdigest(token)
|
||||
@@ -101,8 +111,9 @@ module Api
|
||||
private
|
||||
|
||||
def set_session
|
||||
@session = StreamSession.joins(match: :team)
|
||||
.merge(current_user.manageable_teams)
|
||||
team_ids = current_user.streamable_teams.map(&:id)
|
||||
@session = StreamSession.joins(:match)
|
||||
.where(matches: { team_id: team_ids })
|
||||
.find(params[:id])
|
||||
end
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ module Api
|
||||
module V1
|
||||
class TeamsController < BaseController
|
||||
def index
|
||||
teams = current_user.manageable_teams.includes(:club, :matches)
|
||||
teams = current_user.streamable_teams
|
||||
render json: teams.map { |t| team_json(t) }
|
||||
end
|
||||
|
||||
@@ -27,7 +27,8 @@ module Api
|
||||
end
|
||||
|
||||
def recordings
|
||||
team = current_user.manageable_teams.find(params[:id])
|
||||
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:id].to_s }
|
||||
raise ActiveRecord::RecordNotFound unless team
|
||||
unless team.entitlements.can_access_recordings?
|
||||
return render json: {
|
||||
error: "Archivio gare disponibile con Premium Light o Full",
|
||||
@@ -89,16 +90,16 @@ module Api
|
||||
staff_manage_url: ent.staff_manage_url,
|
||||
max_staff: ent.max_staff,
|
||||
max_staff_transmission: ent.max_staff_transmission,
|
||||
max_staff_regia: ent.max_staff_regia,
|
||||
staff_used: ent.staff_count,
|
||||
staff_transmission_used: ent.staff_count_for("transmission"),
|
||||
staff_regia_used: ent.staff_count_for("regia"),
|
||||
concurrent_streams_used: ent.concurrent_streams_used,
|
||||
concurrent_streams_limit: ent.concurrent_streams_limit,
|
||||
recordings_enabled: ent.can_access_recordings?,
|
||||
phone_download_enabled: ent.phone_download_enabled?,
|
||||
youtube_enabled: ent.youtube_enabled?,
|
||||
youtube_mode: ent.plan.youtube_mode
|
||||
youtube_mode: ent.plan.youtube_mode,
|
||||
staff_role: current_user.staff_role_for(team),
|
||||
can_stream: current_user.can_stream_for?(team)
|
||||
}
|
||||
if detail
|
||||
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
||||
|
||||
70
backend/app/controllers/public/club_billing_controller.rb
Normal file
70
backend/app/controllers/public/club_billing_controller.rb
Normal file
@@ -0,0 +1,70 @@
|
||||
module Public
|
||||
class ClubBillingController < WebBaseController
|
||||
before_action :require_login!
|
||||
before_action :set_club
|
||||
before_action :require_club_owner_for_club!
|
||||
|
||||
def profile
|
||||
end
|
||||
|
||||
def update_profile
|
||||
@club.assign_attributes(billing_profile_params)
|
||||
if @club.save(context: :billing_profile)
|
||||
redirect_to public_club_billing_path(@club), notice: "Dati di fatturazione aggiornati."
|
||||
else
|
||||
flash.now[:alert] = @club.errors.full_messages.join(", ")
|
||||
render :profile, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def sync_payments
|
||||
unless MatchLiveTv.stripe_enabled?
|
||||
redirect_to public_club_billing_path(@club), alert: "Stripe non configurato."
|
||||
return
|
||||
end
|
||||
|
||||
count = Billing::Stripe::SyncPayments.call(club: @club)
|
||||
redirect_to public_club_billing_path(@club), notice: "Sincronizzati #{count} pagamenti da Stripe."
|
||||
rescue ::Stripe::StripeError => e
|
||||
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
||||
end
|
||||
|
||||
def download_invoice
|
||||
invoice = @club.billing_invoices.find(params[:invoice_id])
|
||||
unless invoice.pdf.attached?
|
||||
redirect_to public_club_billing_path(@club), alert: "PDF fattura non disponibile."
|
||||
return
|
||||
end
|
||||
|
||||
redirect_to rails_blob_path(invoice.pdf, disposition: "attachment"), allow_other_host: true
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_club
|
||||
@club = current_user.owned_clubs.find(params[:id])
|
||||
end
|
||||
|
||||
def require_club_owner_for_club!
|
||||
require_club_owner!(@club)
|
||||
end
|
||||
|
||||
def billing_profile_params
|
||||
params.require(:club).permit(
|
||||
: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
|
||||
end
|
||||
end
|
||||
85
backend/app/controllers/public/club_matches_controller.rb
Normal file
85
backend/app/controllers/public/club_matches_controller.rb
Normal file
@@ -0,0 +1,85 @@
|
||||
module Public
|
||||
class ClubMatchesController < WebBaseController
|
||||
before_action :require_login!
|
||||
before_action :set_club
|
||||
before_action :load_schedulable_teams
|
||||
before_action :require_any_schedulable_team!
|
||||
|
||||
def new
|
||||
@match = default_match_for_form
|
||||
@selected_team = resolve_selected_team
|
||||
end
|
||||
|
||||
def create
|
||||
@selected_team = @schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
|
||||
unless @selected_team
|
||||
@match = default_match_for_form
|
||||
flash.now[:alert] = "Seleziona una squadra valida."
|
||||
return render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
@match = @selected_team.matches.create!(
|
||||
match_params.merge(sport: @selected_team.sport, sets_to_win: 3)
|
||||
)
|
||||
redirect_to public_live_index_path(club_id: @club.id),
|
||||
notice: "Partita programmata: #{@selected_team.name} vs #{@match.opponent_name}."
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@match = @selected_team.matches.build(match_params)
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_club
|
||||
@club = Club.find(params[:club_id])
|
||||
end
|
||||
|
||||
def load_schedulable_teams
|
||||
@schedulable_teams = current_user.schedulable_teams_for(@club)
|
||||
@club_admin = @club.owned_by?(current_user)
|
||||
end
|
||||
|
||||
def require_any_schedulable_team!
|
||||
return if @schedulable_teams.any?
|
||||
|
||||
redirect_to public_live_index_path(club_id: @club.id),
|
||||
alert: "Non hai permessi per programmare partite in questa società."
|
||||
end
|
||||
|
||||
def resolve_selected_team
|
||||
if params[:team_id].present?
|
||||
@schedulable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
|
||||
end || @schedulable_teams.first
|
||||
end
|
||||
|
||||
def default_match_for_form
|
||||
team = resolve_selected_team
|
||||
Match.new(
|
||||
sport: team&.sport || @club.sport,
|
||||
sets_to_win: 3,
|
||||
scheduled_at: default_scheduled_at
|
||||
)
|
||||
end
|
||||
|
||||
def match_params
|
||||
p = params.require(:match).permit(:opponent_name, :location, :scheduled_at)
|
||||
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
|
||||
p
|
||||
end
|
||||
|
||||
def parse_scheduled_at(value)
|
||||
raw = value.to_s.strip
|
||||
return nil if raw.blank?
|
||||
|
||||
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
|
||||
rescue ArgumentError
|
||||
Time.zone.parse(raw)
|
||||
end
|
||||
|
||||
def default_scheduled_at
|
||||
t = Time.current + 2.hours
|
||||
Time.zone.local(t.year, t.month, t.day, t.hour, 0, 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,6 +4,7 @@ module Public
|
||||
|
||||
before_action :require_login!
|
||||
before_action :set_club, only: %i[show edit update billing checkout portal]
|
||||
before_action :require_billing_profile_for_premium_checkout!, only: :checkout
|
||||
|
||||
def new
|
||||
redirect_to public_club_path(current_user.primary_club) if current_user.primary_club
|
||||
@@ -42,6 +43,7 @@ module Public
|
||||
|
||||
def show
|
||||
require_club_owner!(@club)
|
||||
apply_checkout_flash!
|
||||
@entitlements_team = @club.teams.first
|
||||
@entitlements = @entitlements_team&.entitlements
|
||||
@teams = @club.teams.order(:name)
|
||||
@@ -64,9 +66,13 @@ module Public
|
||||
|
||||
def billing
|
||||
require_club_owner!(@club)
|
||||
apply_checkout_flash!
|
||||
@team = @club.teams.first!
|
||||
@entitlements = @team.entitlements
|
||||
@subscription = @club.subscription
|
||||
@plans = Plan.ordered
|
||||
@payments = @club.billing_payments.recent.limit(50)
|
||||
@invoices = @club.billing_invoices.recent.limit(50)
|
||||
end
|
||||
|
||||
def checkout
|
||||
@@ -77,8 +83,20 @@ module Public
|
||||
end
|
||||
|
||||
plan_slug = params[:plan].presence_in(%w[premium_light premium_full]) || "premium_light"
|
||||
url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url
|
||||
redirect_to url, allow_other_host: true
|
||||
target_plan = Plan[plan_slug]
|
||||
sub = @club.subscription
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active? && sub.plan.slug != plan_slug
|
||||
Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug)
|
||||
redirect_to public_club_billing_path(@club),
|
||||
notice: "Piano aggiornato a #{target_plan.name}. L'eventuale differenza verrà addebitata o accreditata da Stripe."
|
||||
else
|
||||
url = Billing::Stripe::CheckoutSession.new(club: @club, user: current_user, plan_slug: plan_slug).url
|
||||
redirect_to url, allow_other_host: true
|
||||
end
|
||||
rescue ::Stripe::StripeError => e
|
||||
Rails.logger.warn("[Stripe checkout] #{e.message}")
|
||||
redirect_to public_club_billing_path(@club), alert: "Errore Stripe: #{e.message}"
|
||||
end
|
||||
|
||||
def portal
|
||||
@@ -99,10 +117,34 @@ module Public
|
||||
end
|
||||
|
||||
def club_params
|
||||
p = params.require(:club).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
|
||||
p = 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
|
||||
)
|
||||
p[:primary_color] = normalize_hex_color(p[:primary_color], "#e53935")
|
||||
p[:secondary_color] = normalize_hex_color(p[:secondary_color], "#ffffff")
|
||||
p
|
||||
end
|
||||
|
||||
def require_billing_profile_for_premium_checkout!
|
||||
require_club_owner!(@club)
|
||||
plan_slug = params[:plan].presence_in(%w[premium_light premium_full])
|
||||
return if plan_slug.blank?
|
||||
return if @club.billing_profile_complete?
|
||||
|
||||
redirect_to public_club_billing_profile_path(@club),
|
||||
alert: "Completa i dati di fatturazione prima di attivare un piano premium."
|
||||
end
|
||||
|
||||
def apply_checkout_flash!
|
||||
case params[:checkout]
|
||||
when "success"
|
||||
flash.now[:notice] = "Pagamento completato! Il piano premium è attivo per la società."
|
||||
when "canceled"
|
||||
flash.now[:alert] = "Pagamento annullato. Nessun addebito è stato effettuato."
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -18,7 +18,7 @@ module Public
|
||||
return
|
||||
end
|
||||
invitation.accept!(current_user)
|
||||
redirect_to public_team_dashboard_path(invitation.team), notice: "Sei entrato nella squadra!"
|
||||
redirect_to public_team_details_path(invitation.team), notice: "Sei entrato nella squadra!"
|
||||
else
|
||||
session[:pending_invite_token] = params[:token]
|
||||
redirect_to public_signup_path, notice: "Registrati con #{invitation.email} per accettare l'invito"
|
||||
|
||||
@@ -6,33 +6,46 @@ module Public
|
||||
|
||||
def index
|
||||
@query = params[:q].to_s.strip
|
||||
@sessions = StreamSession
|
||||
@club = Club.find_by(id: params[:club_id]) if params[:club_id].present?
|
||||
team_ids = @club&.teams&.pluck(:id)
|
||||
|
||||
sessions = StreamSession
|
||||
.broadcasting
|
||||
.includes(:score_state, match: :team)
|
||||
.includes(:score_state, match: { team: :club })
|
||||
sessions = sessions.joins(:match).where(matches: { team_id: team_ids }) if team_ids.present?
|
||||
|
||||
@sessions = sessions
|
||||
.search_by_team_or_opponent(@query)
|
||||
.order(Arel.sql("started_at DESC NULLS LAST"), created_at: :desc)
|
||||
|
||||
busy_match_ids = StreamSession.where.not(status: %w[ended error]).select(:match_id)
|
||||
@upcoming_matches = Match
|
||||
.scheduled_upcoming
|
||||
.includes(:team)
|
||||
# Solo dirette già in onda: le partite «programmate» restano visibili anche se c’è sessione idle.
|
||||
broadcasting_match_ids = StreamSession.broadcasting.select(:match_id)
|
||||
upcoming = Match.scheduled_for_live.includes(team: :club)
|
||||
upcoming = upcoming.where(team_id: team_ids) if team_ids.present?
|
||||
|
||||
@upcoming_matches = upcoming
|
||||
.search_teams_or_opponents(@query)
|
||||
.where.not(id: busy_match_ids)
|
||||
.where.not(id: broadcasting_match_ids)
|
||||
.order(scheduled_at: :asc)
|
||||
.limit(30)
|
||||
.limit(50)
|
||||
|
||||
@online_paths = Mediamtx::Client.new.online_path_names
|
||||
|
||||
return unless logged_in? && @club
|
||||
|
||||
@schedulable_teams = current_user.schedulable_teams_for(@club)
|
||||
@can_schedule_match = @schedulable_teams.any?
|
||||
end
|
||||
|
||||
def show
|
||||
@session = StreamSession.includes(match: :team).find(params[:id])
|
||||
@session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||
@match = @session.match
|
||||
@stream_closed = @session.terminal?
|
||||
@on_air = !@stream_closed && mediamtx_online?(@session)
|
||||
end
|
||||
|
||||
def status
|
||||
session = StreamSession.includes(match: :team).find(params[:id])
|
||||
session = StreamSession.includes(match: { team: :club }).find(params[:id])
|
||||
closed = session.terminal?
|
||||
render json: {
|
||||
status: session.status,
|
||||
|
||||
102
backend/app/controllers/public/matches_controller.rb
Normal file
102
backend/app/controllers/public/matches_controller.rb
Normal file
@@ -0,0 +1,102 @@
|
||||
module Public
|
||||
class MatchesController < WebBaseController
|
||||
before_action :require_login!
|
||||
before_action :set_team
|
||||
before_action :require_can_schedule!
|
||||
before_action :set_match, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@matches = @team.matches
|
||||
.includes(:stream_sessions)
|
||||
.order(Arel.sql("scheduled_at ASC NULLS LAST"), created_at: :desc)
|
||||
@can_manage = @team.club.owned_by?(current_user)
|
||||
end
|
||||
|
||||
def new
|
||||
@match = @team.matches.build(
|
||||
sport: @team.sport,
|
||||
sets_to_win: 3,
|
||||
scheduled_at: default_scheduled_at
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@match = @team.matches.create!(
|
||||
match_params.merge(sport: @team.sport, sets_to_win: match_params[:sets_to_win].presence || 3)
|
||||
)
|
||||
redirect_to public_team_matches_path(@team),
|
||||
notice: "Partita programmata: #{@match.opponent_name}. Avvia lo streaming dall’app quando sei in palestra."
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@match = @team.matches.build(match_params)
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
@match.update!(match_params)
|
||||
redirect_to public_team_matches_path(@team), notice: "Partita aggiornata."
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
active = active_session_for(@match)
|
||||
if active&.resumable?
|
||||
redirect_to public_team_matches_path(@team),
|
||||
alert: "Chiudi la diretta prima di eliminare questa partita."
|
||||
return
|
||||
end
|
||||
|
||||
@match.destroy!
|
||||
redirect_to public_team_matches_path(@team), notice: "Partita eliminata."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_user.manageable_teams.find(params[:team_id])
|
||||
@club = @team.club
|
||||
end
|
||||
|
||||
def require_can_schedule!
|
||||
return if current_user.can_schedule_for?(@team)
|
||||
|
||||
redirect_to public_team_details_path(@team),
|
||||
alert: "Solo un responsabile trasmissione o il titolare della società possono programmare le partite."
|
||||
end
|
||||
|
||||
def set_match
|
||||
@match = @team.matches.find(params[:id])
|
||||
end
|
||||
|
||||
def match_params
|
||||
p = params.require(:match).permit(:opponent_name, :location, :scheduled_at, :sport, :sets_to_win)
|
||||
p[:scheduled_at] = parse_scheduled_at(p[:scheduled_at]) if p[:scheduled_at].present?
|
||||
p
|
||||
end
|
||||
|
||||
def parse_scheduled_at(value)
|
||||
raw = value.to_s.strip
|
||||
return nil if raw.blank?
|
||||
|
||||
Time.zone.strptime(raw, "%Y-%m-%dT%H:%M")
|
||||
rescue ArgumentError
|
||||
Time.zone.parse(raw)
|
||||
end
|
||||
|
||||
def default_scheduled_at
|
||||
t = Time.current + 2.hours
|
||||
Time.zone.local(t.year, t.month, t.day, t.hour, 0, 0)
|
||||
end
|
||||
|
||||
def active_session_for(match)
|
||||
match.stream_sessions
|
||||
.order(created_at: :desc)
|
||||
.find { |s| !%w[ended error].include?(s.status) }
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -8,6 +8,20 @@ module Public
|
||||
|
||||
def pricing
|
||||
@plans = Plan.ordered
|
||||
load_club_billing_context_for_pricing
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_club_billing_context_for_pricing
|
||||
return unless logged_in?
|
||||
|
||||
@club = current_user.primary_club
|
||||
return unless @club
|
||||
|
||||
@subscription = @club.subscription
|
||||
@team = @club.teams.order(:name).first
|
||||
@entitlements = @team&.entitlements
|
||||
end
|
||||
|
||||
def privacy
|
||||
|
||||
82
backend/app/controllers/public/regia_controller.rb
Normal file
82
backend/app/controllers/public/regia_controller.rb
Normal file
@@ -0,0 +1,82 @@
|
||||
module Public
|
||||
class RegiaController < SiteBaseController
|
||||
include Public::LiveHelper
|
||||
|
||||
layout "regia"
|
||||
|
||||
protect_from_forgery with: :null_session
|
||||
skip_before_action :verify_authenticity_token, only: %i[score pause stop]
|
||||
|
||||
before_action :set_session_from_token
|
||||
|
||||
def show
|
||||
@match = @session.match
|
||||
@team = @match.team
|
||||
@score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
@rules = Scoring::Rules.from_match(@match)
|
||||
@stream_closed = @session.terminal?
|
||||
@on_air = !@stream_closed && mediamtx_online?(@session)
|
||||
@share_url = request.original_url
|
||||
@share_title = "Regia — #{@team.name} vs #{@match.opponent_name}"
|
||||
end
|
||||
|
||||
def status
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def score
|
||||
cmd = params[:cmd].presence || params[:score_action].presence
|
||||
result = Scoring::ApplyAction.new(session: @session, action: cmd).call
|
||||
render json: status_payload.merge(
|
||||
set_won: result.set_won,
|
||||
match_won: result.match_won,
|
||||
winner: result.winner&.to_s
|
||||
)
|
||||
rescue ArgumentError => e
|
||||
render json: { error: e.message }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def pause
|
||||
Sessions::Pause.new(@session).call
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
def stop
|
||||
Sessions::Stop.new(@session).call
|
||||
render json: status_payload
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_session_from_token
|
||||
@session = Sessions::RegiaAccess.find_session_by_token(params[:token])
|
||||
return if @session
|
||||
|
||||
render file: Rails.root.join("public/404.html"), status: :not_found, layout: false
|
||||
throw :abort
|
||||
end
|
||||
|
||||
def status_payload
|
||||
score = @session.score_state
|
||||
closed = @session.terminal?
|
||||
{
|
||||
status: @session.status,
|
||||
stream_closed: closed,
|
||||
live: !closed && (@session.live? || mediamtx_online?(@session)),
|
||||
on_air: !closed && mediamtx_online?(@session),
|
||||
ended_at: @session.ended_at,
|
||||
home_name: @session.match.team.name,
|
||||
away_name: @session.match.opponent_name,
|
||||
points_target: Scoring::Rules.from_match(@session.match).points_target(score&.current_set || 1),
|
||||
score: score&.as_cable_payload
|
||||
}
|
||||
end
|
||||
|
||||
def mediamtx_online?(session)
|
||||
@online_paths_cache ||= Mediamtx::Client.new.online_path_names
|
||||
@online_paths_cache.include?(session.mediamtx_path_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -20,7 +20,7 @@ module Public
|
||||
invitation = TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token))
|
||||
if invitation && invitation.email.downcase == @user.email.downcase
|
||||
invitation.accept!(@user)
|
||||
return redirect_to public_team_dashboard_path(invitation.team), notice: "Benvenuto nella squadra!"
|
||||
return redirect_to public_team_details_path(invitation.team), notice: "Benvenuto nella squadra!"
|
||||
end
|
||||
end
|
||||
redirect_to public_new_club_path, notice: "Account creato. Ora registra la tua società."
|
||||
|
||||
@@ -13,7 +13,7 @@ module Public
|
||||
dest = if user.primary_club
|
||||
public_club_path(user.primary_club)
|
||||
elsif user.manageable_teams.first
|
||||
public_team_dashboard_path(user.manageable_teams.first)
|
||||
public_team_details_path(user.manageable_teams.first)
|
||||
else
|
||||
public_new_club_path
|
||||
end
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
module Public
|
||||
class TeamRosterMembersController < WebBaseController
|
||||
before_action :require_login!
|
||||
before_action :set_team
|
||||
before_action :require_team_owner!
|
||||
before_action :set_member, only: %i[edit update destroy]
|
||||
|
||||
def create
|
||||
member = @team.roster_members.build(member_params)
|
||||
attach_photo(member)
|
||||
member.save!
|
||||
redirect_to public_team_details_path(@team), notice: "#{member.full_name} aggiunto all'organico."
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
redirect_to public_team_details_path(@team), alert: e.record.errors.full_messages.join(", ")
|
||||
end
|
||||
|
||||
def edit
|
||||
@club = @team.club
|
||||
end
|
||||
|
||||
def update
|
||||
@member.assign_attributes(member_params)
|
||||
attach_photo(@member)
|
||||
@member.save!
|
||||
redirect_to public_team_details_path(@team), notice: "Scheda aggiornata."
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@club = @team.club
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def destroy
|
||||
name = @member.full_name
|
||||
@member.destroy!
|
||||
redirect_to public_team_details_path(@team), notice: "#{name} rimosso dall'organico."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_team
|
||||
@team = current_user.manageable_teams.find(params[:team_id])
|
||||
end
|
||||
|
||||
def require_team_owner!
|
||||
require_club_owner_for_team!(@team)
|
||||
end
|
||||
|
||||
def set_member
|
||||
@member = @team.roster_members.find(params[:id])
|
||||
end
|
||||
|
||||
def member_params
|
||||
params.require(:team_roster_member).permit(
|
||||
:category, :full_name, :role_label, :jersey_number, :bio, :position
|
||||
)
|
||||
end
|
||||
|
||||
def attach_photo(member)
|
||||
file = params.dig(:team_roster_member, :photo_file)
|
||||
member.photo_file.attach(file) if file.present?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ module Public
|
||||
before_action :require_login!
|
||||
before_action :set_club, only: %i[new create]
|
||||
before_action :set_team, only: %i[
|
||||
dashboard edit update invite create_invitation
|
||||
details dashboard roster edit update invite create_invitation
|
||||
assign_self_staff clear_self_staff remove_member destroy_invitation
|
||||
]
|
||||
|
||||
@@ -24,15 +24,17 @@ module Public
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
def details
|
||||
load_team_details!
|
||||
render :details
|
||||
end
|
||||
|
||||
def dashboard
|
||||
require_club_owner_for_team!(@team)
|
||||
@club = @team.club
|
||||
@entitlements = @team.entitlements
|
||||
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
|
||||
@owner_membership = current_user.user_teams.find_by(team: @team)
|
||||
@staff_memberships = @team.user_teams.includes(:user)
|
||||
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")
|
||||
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
|
||||
redirect_to public_team_details_path(params[:id])
|
||||
end
|
||||
|
||||
def roster
|
||||
redirect_to public_team_details_path(params[:id])
|
||||
end
|
||||
|
||||
def edit
|
||||
@@ -44,8 +46,9 @@ module Public
|
||||
require_club_owner_for_team!(@team)
|
||||
@team.assign_attributes(team_params)
|
||||
attach_branding_logo(@team)
|
||||
attach_team_photo(@team)
|
||||
@team.save!
|
||||
redirect_to public_team_dashboard_path(@team), notice: "Squadra aggiornata."
|
||||
redirect_to public_team_details_path(@team), notice: "Squadra aggiornata."
|
||||
rescue ActiveRecord::RecordInvalid => e
|
||||
@club = @team.club
|
||||
flash.now[:alert] = e.record.errors.full_messages.join(", ")
|
||||
@@ -59,27 +62,25 @@ module Public
|
||||
|
||||
def assign_self_staff
|
||||
require_club_owner_for_team!(@team)
|
||||
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
|
||||
membership = current_user.user_teams.find_or_initialize_by(team: @team)
|
||||
membership.role = "member" if membership.new_record?
|
||||
Teams::StaffAssignment.call(team: @team, user: current_user, staff_kind: staff_kind, membership: membership)
|
||||
label = staff_kind == "regia" ? "Regia" : "Trasmissione"
|
||||
redirect_to public_team_dashboard_path(@team), notice: "Il tuo account è ora staff #{label}."
|
||||
Teams::StaffAssignment.call(team: @team, user: current_user, membership: membership)
|
||||
redirect_to public_team_details_path(@team), notice: "Il tuo account è ora responsabile trasmissione."
|
||||
rescue Teams::StaffAssignmentError, Teams::EntitlementError => e
|
||||
redirect_to public_team_dashboard_path(@team), alert: e.message
|
||||
redirect_to public_team_details_path(@team), alert: e.message
|
||||
end
|
||||
|
||||
def clear_self_staff
|
||||
require_club_owner_for_team!(@team)
|
||||
membership = current_user.user_teams.find_by(team: @team)
|
||||
membership&.update!(staff_kind: nil)
|
||||
redirect_to public_team_dashboard_path(@team), notice: "Ruolo staff rimosso dal tuo account."
|
||||
redirect_to public_team_details_path(@team), notice: "Ruolo staff rimosso dal tuo account."
|
||||
end
|
||||
|
||||
def create_invitation
|
||||
require_club_owner_for_team!(@team)
|
||||
@entitlements = @team.entitlements
|
||||
staff_kind = params[:staff_kind].presence_in(TeamInvitation::STAFF_KINDS) || "transmission"
|
||||
staff_kind = "transmission"
|
||||
@entitlements.assert_can_invite!(staff_kind: staff_kind)
|
||||
|
||||
email = params[:email]&.downcase&.strip
|
||||
@@ -103,18 +104,35 @@ module Public
|
||||
require_club_owner_for_team!(@team)
|
||||
ut = @team.user_teams.find_by!(user_id: params[:user_id], role: "member")
|
||||
ut.destroy!
|
||||
redirect_to public_team_dashboard_path(@team), notice: "Accesso revocato"
|
||||
redirect_to public_team_details_path(@team), notice: "Accesso revocato"
|
||||
end
|
||||
|
||||
def destroy_invitation
|
||||
require_club_owner_for_team!(@team)
|
||||
inv = @team.team_invitations.pending.find(params[:invitation_id])
|
||||
inv.destroy!
|
||||
redirect_to public_team_dashboard_path(@team), notice: "Invito annullato"
|
||||
redirect_to public_team_details_path(@team), notice: "Invito annullato"
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def load_team_details!
|
||||
@club = @team.club
|
||||
@can_manage = @club.owned_by?(current_user)
|
||||
@entitlements = @team.entitlements
|
||||
@roster_by_category = @team.roster_by_category
|
||||
if @can_manage
|
||||
@roster_member = @team.roster_members.build(
|
||||
category: params[:category].presence_in(TeamRosterMember::CATEGORIES) || "player"
|
||||
)
|
||||
end
|
||||
@recordings = @team.recordings.ready.includes(stream_session: :match).order(created_at: :desc).limit(10)
|
||||
@owner_membership = current_user.user_teams.find_by(team: @team)
|
||||
@staff_memberships = @team.user_teams.includes(:user)
|
||||
.where("user_teams.role = 'member' OR (user_teams.staff_kind IS NOT NULL)")
|
||||
@pending_invitations = @team.team_invitations.pending.order(created_at: :desc)
|
||||
end
|
||||
|
||||
def set_club
|
||||
@club = current_user.owned_clubs.find(params[:club_id])
|
||||
end
|
||||
@@ -123,8 +141,13 @@ module Public
|
||||
@team = current_user.manageable_teams.find(params[:id])
|
||||
end
|
||||
|
||||
def attach_team_photo(team)
|
||||
file = params.dig(:team, :photo_file)
|
||||
team.photo_file.attach(file) if file.present?
|
||||
end
|
||||
|
||||
def team_params
|
||||
p = params.require(:team).permit(:name, :sport, :logo_url, :primary_color, :secondary_color)
|
||||
p = params.require(:team).permit(:name, :sport, :description, :logo_url, :primary_color, :secondary_color)
|
||||
p[:primary_color] = p[:primary_color].presence
|
||||
p[:secondary_color] = p[:secondary_color].presence
|
||||
if p[:primary_color].present?
|
||||
|
||||
29
backend/app/helpers/public/billing_helper.rb
Normal file
29
backend/app/helpers/public/billing_helper.rb
Normal file
@@ -0,0 +1,29 @@
|
||||
module Public
|
||||
module BillingHelper
|
||||
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:)
|
||||
return { kind: :current, label: "Piano attuale" } if current_slug == target_plan.slug
|
||||
|
||||
if target_plan.slug == "free"
|
||||
return { kind: :contact, label: "Contatta il supporto per downgrade." }
|
||||
end
|
||||
|
||||
unless MatchLiveTv.stripe_enabled?
|
||||
return { kind: :disabled, label: "Stripe non configurato" }
|
||||
end
|
||||
|
||||
if current_slug == "free" || !stripe_subscription_active
|
||||
return { kind: :checkout, label: "Attiva #{target_plan.name}" }
|
||||
end
|
||||
|
||||
if Plan.tier(target_plan.slug) > Plan.tier(current_slug)
|
||||
{ kind: :change, label: "Passa a #{target_plan.name}" }
|
||||
else
|
||||
{ kind: :change, label: "Passa a #{target_plan.name}" }
|
||||
end
|
||||
end
|
||||
|
||||
def stripe_subscription_active?(subscription)
|
||||
subscription&.stripe_subscription_id.present? && subscription.active?
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -33,6 +33,28 @@ module Public
|
||||
end
|
||||
end
|
||||
|
||||
def live_match_card_heading(match)
|
||||
team = match.team
|
||||
club_name = team.club&.name.presence || "Società"
|
||||
content_tag(:h3, class: "live-card__title") do
|
||||
safe_join([
|
||||
content_tag(:span, club_name, class: "live-card__club"),
|
||||
content_tag(:span, "#{team.name} vs #{match.opponent_name}", class: "live-card__matchup")
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
def live_match_page_heading(match)
|
||||
team = match.team
|
||||
club_name = team.club&.name.presence || "Società"
|
||||
content_tag(:div, class: "live-page-heading") do
|
||||
safe_join([
|
||||
content_tag(:p, club_name, class: "live-page-heading__club"),
|
||||
content_tag(:h1, "#{team.name} vs #{match.opponent_name}", class: "live-page-heading__matchup")
|
||||
])
|
||||
end
|
||||
end
|
||||
|
||||
def live_score_points_label(match, score_state)
|
||||
return "—" unless score_state
|
||||
|
||||
|
||||
32
backend/app/helpers/roster_helper.rb
Normal file
32
backend/app/helpers/roster_helper.rb
Normal file
@@ -0,0 +1,32 @@
|
||||
module RosterHelper
|
||||
def roster_person_avatar(person, team, size: 96)
|
||||
primary = team.effective_primary_color
|
||||
secondary = team.effective_secondary_color
|
||||
style = "--avatar-primary:#{primary};--avatar-secondary:#{secondary};width:#{size}px;height:#{size}px"
|
||||
|
||||
if person.photo_url.present?
|
||||
image_tag person.photo_url, alt: person.full_name, class: "roster-avatar roster-avatar--photo",
|
||||
style: style, width: size, height: size
|
||||
else
|
||||
tag.div(
|
||||
person.initials,
|
||||
class: "roster-avatar roster-avatar--placeholder",
|
||||
style: "#{style};font-size:#{(size * 0.34).round}px",
|
||||
aria: { label: person.full_name }
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def roster_category_options
|
||||
TeamRosterMember::DISPLAY_ORDER.map do |cat|
|
||||
[TeamRosterMember::CATEGORY_LABELS[cat], cat]
|
||||
end
|
||||
end
|
||||
|
||||
def roster_player_role_options(selected = nil)
|
||||
options_for_select(
|
||||
[["— Seleziona ruolo —", ""]] + TeamRosterMember::VOLLEYBALL_PLAYER_ROLES.map { |r| [r, r] },
|
||||
selected
|
||||
)
|
||||
end
|
||||
end
|
||||
9
backend/app/helpers/staff_helper.rb
Normal file
9
backend/app/helpers/staff_helper.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module StaffHelper
|
||||
def staff_role_label(membership, team)
|
||||
Teams::StaffCoverage.new(team).role_label_for(membership)
|
||||
end
|
||||
|
||||
def staff_coverage_for(team)
|
||||
Teams::StaffCoverage.new(team)
|
||||
end
|
||||
end
|
||||
43
backend/app/models/billing/invoice.rb
Normal file
43
backend/app/models/billing/invoice.rb
Normal file
@@ -0,0 +1,43 @@
|
||||
module Billing
|
||||
class Invoice < ApplicationRecord
|
||||
self.table_name = "billing_invoices"
|
||||
|
||||
STATUSES = %w[draft issued sent cancelled].freeze
|
||||
SOURCES = %w[aruba manual].freeze
|
||||
|
||||
belongs_to :club
|
||||
belongs_to :billing_payment, class_name: "Billing::Payment", optional: true
|
||||
has_one_attached :pdf
|
||||
|
||||
validates :number, presence: true, uniqueness: { scope: :club_id }
|
||||
validates :issued_on, presence: true
|
||||
validates :amount_cents, numericality: { greater_than: 0 }
|
||||
validates :currency, presence: true
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :source, inclusion: { in: SOURCES }
|
||||
validate :pdf_present_when_issued
|
||||
|
||||
scope :recent, -> { order(issued_on: :desc, created_at: :desc) }
|
||||
|
||||
def amount_euros
|
||||
amount_cents / 100.0
|
||||
end
|
||||
|
||||
def formatted_amount
|
||||
format("%.2f €", amount_euros)
|
||||
end
|
||||
|
||||
def downloadable?
|
||||
pdf.attached?
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def pdf_present_when_issued
|
||||
return unless status.in?(%w[issued sent])
|
||||
return if pdf.attached?
|
||||
|
||||
errors.add(:pdf, "è obbligatorio per le fatture emesse")
|
||||
end
|
||||
end
|
||||
end
|
||||
25
backend/app/models/billing/payment.rb
Normal file
25
backend/app/models/billing/payment.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
module Billing
|
||||
class Payment < ApplicationRecord
|
||||
self.table_name = "billing_payments"
|
||||
|
||||
STATUSES = %w[paid failed refunded].freeze
|
||||
|
||||
belongs_to :club
|
||||
has_one :invoice, class_name: "Billing::Invoice", foreign_key: :billing_payment_id, dependent: :nullify
|
||||
|
||||
validates :amount_cents, numericality: { greater_than: 0 }
|
||||
validates :currency, presence: true
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :stripe_invoice_id, uniqueness: true, allow_nil: true
|
||||
|
||||
scope :recent, -> { order(paid_at: :desc, created_at: :desc) }
|
||||
|
||||
def amount_euros
|
||||
amount_cents / 100.0
|
||||
end
|
||||
|
||||
def formatted_amount
|
||||
format("%.2f €", amount_euros)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,13 @@
|
||||
class Club < ApplicationRecord
|
||||
include Brandable
|
||||
include ClubBillingProfile
|
||||
|
||||
has_many :club_memberships, dependent: :destroy
|
||||
has_many :users, through: :club_memberships
|
||||
has_many :teams, dependent: :destroy
|
||||
has_one :subscription, dependent: :destroy
|
||||
has_many :billing_payments, class_name: "Billing::Payment", dependent: :destroy
|
||||
has_many :billing_invoices, class_name: "Billing::Invoice", dependent: :destroy
|
||||
|
||||
validates :name, presence: true
|
||||
validates :sport, presence: true
|
||||
|
||||
47
backend/app/models/concerns/club_billing_profile.rb
Normal file
47
backend/app/models/concerns/club_billing_profile.rb
Normal file
@@ -0,0 +1,47 @@
|
||||
module ClubBillingProfile
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
BILLING_ENTITY_TYPES = {
|
||||
"company" => "Società / ASD con P.IVA",
|
||||
"individual" => "Persona fisica",
|
||||
"nonprofit" => "Associazione senza scopo di lucro"
|
||||
}.freeze
|
||||
|
||||
included do
|
||||
validates :billing_entity_type, inclusion: { in: BILLING_ENTITY_TYPES.keys }, allow_nil: true
|
||||
validates :billing_email, format: { with: URI::MailTo::EMAIL_REGEXP }, allow_blank: true
|
||||
validates :billing_recipient_code, length: { is: 7 }, allow_blank: true
|
||||
validates :billing_province, length: { is: 2 }, allow_blank: true
|
||||
validates :billing_country, length: { is: 2 }, allow_blank: true
|
||||
validate :billing_profile_for_invoicing, on: :billing_profile
|
||||
end
|
||||
|
||||
def billing_profile_complete?
|
||||
billing_profile_errors.empty?
|
||||
end
|
||||
|
||||
def billing_profile_errors
|
||||
errors = []
|
||||
errors << "Ragione sociale o nome intestatario" if billing_legal_name.blank?
|
||||
errors << "Email di fatturazione" if billing_email.blank?
|
||||
errors << "Indirizzo" if billing_address_line.blank?
|
||||
errors << "Città" if billing_city.blank?
|
||||
errors << "CAP" if billing_postal_code.blank?
|
||||
errors << "P.IVA o Codice Fiscale" if billing_vat_number.blank? && billing_fiscal_code.blank?
|
||||
errors << "Codice destinatario SDI o PEC" if billing_recipient_code.blank? && billing_pec.blank?
|
||||
errors
|
||||
end
|
||||
|
||||
def billing_profile_summary
|
||||
parts = [billing_legal_name.presence, billing_vat_number.presence && "P.IVA #{billing_vat_number}"]
|
||||
parts.compact.join(" · ")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def billing_profile_for_invoicing
|
||||
billing_profile_errors.each do |message|
|
||||
errors.add(:base, message)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,17 +7,47 @@ class Match < ApplicationRecord
|
||||
validates :opponent_name, presence: true
|
||||
validates :sets_to_win, numericality: { greater_than: 0, less_than: 6 }
|
||||
|
||||
scope :scheduled_upcoming, -> {
|
||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.current)
|
||||
# Partite con data/ora: da inizio giornata (Europe/Rome) in poi — allineato alla pagina Partite.
|
||||
scope :scheduled_for_live, -> {
|
||||
where.not(scheduled_at: nil).where("scheduled_at >= ?", Time.zone.now.beginning_of_day)
|
||||
}
|
||||
|
||||
scope :scheduled_upcoming, -> { scheduled_for_live }
|
||||
|
||||
def scheduled_upcoming?
|
||||
scheduled_at.present? && scheduled_at >= Time.zone.now
|
||||
end
|
||||
|
||||
def scheduled_on_calendar?
|
||||
scheduled_at.present? && scheduled_at >= Time.zone.now.beginning_of_day
|
||||
end
|
||||
|
||||
def active_stream_session
|
||||
stream_sessions.sort_by { |s| s.created_at }.reverse
|
||||
.find { |s| !%w[ended error].include?(s.status) }
|
||||
end
|
||||
|
||||
def deletable?
|
||||
session = active_stream_session
|
||||
session.nil? || !session.resumable?
|
||||
end
|
||||
|
||||
def public_status_label
|
||||
active = active_stream_session
|
||||
return "In corso / da riprendere" if active
|
||||
return "Programmata" if scheduled_upcoming?
|
||||
return "Pronta" if scheduled_at.present?
|
||||
|
||||
"Senza orario"
|
||||
end
|
||||
|
||||
scope :search_teams_or_opponents, lambda { |query|
|
||||
q = query.to_s.strip
|
||||
return all if q.blank?
|
||||
|
||||
term = "%#{sanitize_sql_like(q)}%"
|
||||
joins(:team).where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term",
|
||||
joins(team: :club).where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
|
||||
term: term
|
||||
)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,14 @@ class Plan < ApplicationRecord
|
||||
find_by!(slug: slug.to_s)
|
||||
end
|
||||
|
||||
def self.tier(slug)
|
||||
SLUGS.index(slug.to_s) || 0
|
||||
end
|
||||
|
||||
def tier
|
||||
self.class.tier(slug)
|
||||
end
|
||||
|
||||
def allows_platform?(platform)
|
||||
return false if platform.to_s == "youtube" && !youtube_enabled?
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ class StreamSession < ApplicationRecord
|
||||
return all if q.blank?
|
||||
|
||||
term = "%#{sanitize_sql_like(q)}%"
|
||||
joins(match: :team).where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term",
|
||||
joins(match: { team: :club }).where(
|
||||
"teams.name ILIKE :term OR matches.opponent_name ILIKE :term OR clubs.name ILIKE :term OR matches.location ILIKE :term",
|
||||
term: term
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,8 +8,12 @@ class Team < ApplicationRecord
|
||||
has_one :youtube_credential, dependent: :destroy
|
||||
has_many :recordings, dependent: :destroy
|
||||
has_many :team_invitations, dependent: :destroy
|
||||
has_many :roster_members, class_name: "TeamRosterMember", dependent: :destroy
|
||||
|
||||
has_one_attached :photo_file
|
||||
|
||||
validates :name, presence: true
|
||||
validate :photo_file_type, if: -> { photo_file.attached? }
|
||||
|
||||
def subscription
|
||||
club.subscription
|
||||
@@ -23,8 +27,26 @@ class Team < ApplicationRecord
|
||||
club&.owner
|
||||
end
|
||||
|
||||
def team_photo_url
|
||||
if photo_file.attached?
|
||||
Rails.application.routes.url_helpers.rails_blob_path(photo_file, only_path: true)
|
||||
end
|
||||
end
|
||||
|
||||
def roster_by_category
|
||||
TeamRosterMember::DISPLAY_ORDER.index_with do |cat|
|
||||
roster_members.by_category(cat).to_a
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def photo_file_type
|
||||
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||
|
||||
errors.add(:photo_file, "deve essere PNG, JPEG o WebP")
|
||||
end
|
||||
|
||||
def branding_parent
|
||||
club
|
||||
end
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class TeamInvitation < ApplicationRecord
|
||||
STAFF_KINDS = %w[transmission regia].freeze
|
||||
STAFF_KINDS = %w[transmission].freeze
|
||||
|
||||
belongs_to :team
|
||||
|
||||
|
||||
87
backend/app/models/team_roster_member.rb
Normal file
87
backend/app/models/team_roster_member.rb
Normal file
@@ -0,0 +1,87 @@
|
||||
class TeamRosterMember < ApplicationRecord
|
||||
CATEGORIES = %w[staff coach manager player].freeze
|
||||
# Ordine di visualizzazione in pagina organico
|
||||
DISPLAY_ORDER = %w[coach manager player staff].freeze
|
||||
CATEGORY_LABELS = {
|
||||
"staff" => "Staff",
|
||||
"coach" => "Allenatori",
|
||||
"manager" => "Dirigenti",
|
||||
"player" => "Giocatori"
|
||||
}.freeze
|
||||
|
||||
VOLLEYBALL_PLAYER_ROLES = %w[
|
||||
Schiacciatore
|
||||
Opposto
|
||||
Centrale
|
||||
Palleggiatore
|
||||
Libero
|
||||
Universale
|
||||
].freeze
|
||||
|
||||
belongs_to :team
|
||||
|
||||
has_one_attached :photo_file
|
||||
|
||||
validates :full_name, presence: true
|
||||
validates :category, inclusion: { in: CATEGORIES }
|
||||
validates :jersey_number, numericality: { only_integer: true, greater_than: 0, less_than: 100 }, allow_nil: true
|
||||
validate :photo_file_type, if: -> { photo_file.attached? }
|
||||
validate :player_role_label_valid, if: -> { category == "player" && role_label.present? }
|
||||
|
||||
before_validation :assign_position, on: :create
|
||||
|
||||
scope :ordered, -> { order(:position, :created_at) }
|
||||
scope :by_category, ->(cat) { where(category: cat).ordered }
|
||||
|
||||
def category_label
|
||||
CATEGORY_LABELS[category] || category
|
||||
end
|
||||
|
||||
def initials
|
||||
parts = full_name.to_s.split(/\s+/).reject(&:blank?)
|
||||
return "?" if parts.empty?
|
||||
|
||||
parts.first(2).map { |p| p[0] }.join.upcase
|
||||
end
|
||||
|
||||
def photo_url
|
||||
return unless photo_file.attached?
|
||||
|
||||
Rails.application.routes.url_helpers.rails_blob_path(photo_file, only_path: true)
|
||||
end
|
||||
|
||||
def display_role
|
||||
role_label.presence || default_role_label
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def assign_position
|
||||
return if position.positive? || team.blank?
|
||||
|
||||
max_pos = team.roster_members.where(category: category).maximum(:position) || -1
|
||||
self.position = max_pos + 1
|
||||
end
|
||||
|
||||
def default_role_label
|
||||
case category
|
||||
when "coach" then "Allenatore"
|
||||
when "manager" then "Dirigente"
|
||||
when "staff" then "Staff"
|
||||
when "player" then jersey_number.present? ? "Giocatore ##{jersey_number}" : "Giocatore"
|
||||
else category
|
||||
end
|
||||
end
|
||||
|
||||
def player_role_label_valid
|
||||
return if role_label.in?(VOLLEYBALL_PLAYER_ROLES)
|
||||
|
||||
errors.add(:role_label, "non è un ruolo pallavolo valido")
|
||||
end
|
||||
|
||||
def photo_file_type
|
||||
return if photo_file.content_type.in?(%w[image/png image/jpeg image/webp])
|
||||
|
||||
errors.add(:photo_file, "deve essere PNG, JPEG o WebP")
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,51 @@ class User < ApplicationRecord
|
||||
Team.where(id: staff_ids).or(Team.where(id: owner_ids))
|
||||
end
|
||||
|
||||
# Squadre da cui l'utente può programmare partite e avviare lo streaming (app).
|
||||
def streamable_teams
|
||||
manageable_teams.includes(:club).select { |team| can_stream_for?(team) }
|
||||
end
|
||||
|
||||
def club_admin?(club)
|
||||
club&.owned_by?(self)
|
||||
end
|
||||
|
||||
def can_schedule_for?(team)
|
||||
return true if team.club&.owned_by?(self)
|
||||
|
||||
membership = user_teams.find_by(team: team)
|
||||
return false if membership&.staff_kind.blank?
|
||||
|
||||
membership.staff_kind == "transmission"
|
||||
end
|
||||
|
||||
def schedulable_teams_for(club)
|
||||
teams = club.teams.order(:name).to_a
|
||||
return teams if club.owned_by?(self)
|
||||
|
||||
teams.select { |team| can_schedule_for?(team) }
|
||||
end
|
||||
|
||||
def can_stream_for?(team)
|
||||
return true if team.club&.owned_by?(self)
|
||||
|
||||
membership = user_teams.find_by(team: team)
|
||||
return false if membership&.staff_kind.blank?
|
||||
|
||||
return true if membership.staff_kind == "transmission"
|
||||
|
||||
Teams::StaffCoverage.new(team).covers_both_roles?(membership)
|
||||
end
|
||||
|
||||
def staff_role_for(team)
|
||||
return "owner" if team.club&.owned_by?(self)
|
||||
|
||||
membership = user_teams.find_by(team: team)
|
||||
return nil unless membership&.staff_kind.present?
|
||||
|
||||
"transmission"
|
||||
end
|
||||
|
||||
def primary_club
|
||||
owned_clubs.order(:created_at).first
|
||||
end
|
||||
|
||||
76
backend/app/services/billing/stripe/change_plan.rb
Normal file
76
backend/app/services/billing/stripe/change_plan.rb
Normal file
@@ -0,0 +1,76 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class ChangePlan
|
||||
VALID_PLANS = CheckoutSession::VALID_PLANS.freeze
|
||||
|
||||
def self.call(club:, plan_slug:)
|
||||
new(club: club, plan_slug: plan_slug).call
|
||||
end
|
||||
|
||||
def initialize(club:, plan_slug:)
|
||||
@club = club
|
||||
@plan_slug = plan_slug.to_s
|
||||
end
|
||||
|
||||
def call
|
||||
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
|
||||
|
||||
sub = @club.subscription
|
||||
raise "Nessun abbonamento Stripe attivo" if sub.blank? || sub.stripe_subscription_id.blank?
|
||||
raise "Sei già su questo piano" if sub.plan.slug == @plan_slug
|
||||
|
||||
price_id = Plan[@plan_slug].stripe_price_id.presence || stripe_price_id_for(@plan_slug)
|
||||
raise "Price ID Stripe mancante per #{@plan_slug}" if price_id.blank?
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||
item_id = stripe_sub.items.data.first.id
|
||||
|
||||
updated = ::Stripe::Subscription.update(
|
||||
stripe_sub.id,
|
||||
items: [{ id: item_id, price: price_id }],
|
||||
metadata: { club_id: @club.id, plan_slug: @plan_slug },
|
||||
proration_behavior: "create_prorations"
|
||||
)
|
||||
|
||||
apply_local_plan!(updated)
|
||||
updated
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def stripe_price_id_for(slug)
|
||||
case slug
|
||||
when "premium_light" then MatchLiveTv.stripe_premium_light_price_id
|
||||
when "premium_full" then MatchLiveTv.stripe_premium_full_price_id
|
||||
end
|
||||
end
|
||||
|
||||
def apply_local_plan!(stripe_sub)
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
Billing::AssignPlan.call(
|
||||
club: @club,
|
||||
plan_slug: @plan_slug,
|
||||
status: map_status(stripe_sub.status),
|
||||
stripe_attrs: {
|
||||
stripe_customer_id: stripe_sub.customer,
|
||||
stripe_subscription_id: stripe_sub.id,
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def map_status(stripe_status)
|
||||
case stripe_status
|
||||
when "trialing" then "trialing"
|
||||
when "active" then "active"
|
||||
when "past_due", "unpaid" then "past_due"
|
||||
when "canceled", "incomplete_expired" then "canceled"
|
||||
else "incomplete"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -64,11 +64,15 @@ module Billing
|
||||
end
|
||||
|
||||
def success_url
|
||||
"#{dashboard_url}?checkout=success"
|
||||
"#{billing_url}?checkout=success"
|
||||
end
|
||||
|
||||
def cancel_url
|
||||
"#{dashboard_url}?checkout=canceled"
|
||||
"#{billing_url}?checkout=canceled"
|
||||
end
|
||||
|
||||
def billing_url
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/clubs/#{@club.id}/billing"
|
||||
end
|
||||
|
||||
def dashboard_url
|
||||
|
||||
90
backend/app/services/billing/stripe/record_payment.rb
Normal file
90
backend/app/services/billing/stripe/record_payment.rb
Normal file
@@ -0,0 +1,90 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class RecordPayment
|
||||
def self.call(stripe_invoice:, club: nil)
|
||||
new(stripe_invoice: stripe_invoice, club: club).call
|
||||
end
|
||||
|
||||
def initialize(stripe_invoice:, club: nil)
|
||||
@stripe_invoice = stripe_invoice
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
@club ||= resolve_club
|
||||
return unless @club
|
||||
|
||||
payment = Billing::Payment.find_or_initialize_by(stripe_invoice_id: @stripe_invoice.id)
|
||||
payment.assign_attributes(
|
||||
club: @club,
|
||||
stripe_payment_intent_id: payment_intent_id,
|
||||
stripe_charge_id: charge_id,
|
||||
amount_cents: @stripe_invoice.amount_paid,
|
||||
currency: @stripe_invoice.currency,
|
||||
status: map_status(@stripe_invoice.status),
|
||||
plan_slug: plan_slug,
|
||||
description: description,
|
||||
paid_at: paid_at,
|
||||
invoice_pdf_url: @stripe_invoice.invoice_pdf,
|
||||
receipt_url: @stripe_invoice.hosted_invoice_url
|
||||
)
|
||||
payment.save!
|
||||
payment
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def resolve_club
|
||||
club_id = metadata_club_id(@stripe_invoice)
|
||||
return Club.find_by(id: club_id) if club_id.present?
|
||||
|
||||
sub_id = @stripe_invoice.subscription
|
||||
return unless sub_id.present?
|
||||
|
||||
Club.joins(:subscription).find_by(subscriptions: { stripe_subscription_id: sub_id })
|
||||
end
|
||||
|
||||
def metadata_club_id(obj)
|
||||
meta = obj.metadata
|
||||
return nil if meta.blank?
|
||||
|
||||
meta["club_id"] || meta[:club_id]
|
||||
end
|
||||
|
||||
def plan_slug
|
||||
meta = @stripe_invoice.subscription_details&.metadata || @stripe_invoice.metadata
|
||||
meta["plan_slug"] || meta[:plan_slug]
|
||||
end
|
||||
|
||||
def description
|
||||
lines = @stripe_invoice.lines&.data
|
||||
return @stripe_invoice.description if lines.blank?
|
||||
|
||||
lines.first.description.presence || @stripe_invoice.description
|
||||
end
|
||||
|
||||
def payment_intent_id
|
||||
pi = @stripe_invoice.payment_intent
|
||||
pi.is_a?(String) ? pi : pi&.id
|
||||
end
|
||||
|
||||
def charge_id
|
||||
charge = @stripe_invoice.charge
|
||||
charge.is_a?(String) ? charge : charge&.id
|
||||
end
|
||||
|
||||
def paid_at
|
||||
ts = @stripe_invoice.status_transitions&.paid_at
|
||||
ts ? Time.zone.at(ts) : Time.current
|
||||
end
|
||||
|
||||
def map_status(stripe_status)
|
||||
case stripe_status
|
||||
when "paid" then "paid"
|
||||
when "uncollectible", "void" then "failed"
|
||||
else "failed"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
27
backend/app/services/billing/stripe/subscription_period.rb
Normal file
27
backend/app/services/billing/stripe/subscription_period.rb
Normal file
@@ -0,0 +1,27 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
module SubscriptionPeriod
|
||||
module_function
|
||||
|
||||
def times(stripe_sub)
|
||||
item = stripe_sub.items&.data&.first
|
||||
start_at = read_value(stripe_sub, :current_period_start) || read_value(item, :current_period_start)
|
||||
end_at = read_value(stripe_sub, :current_period_end) || read_value(item, :current_period_end)
|
||||
[unix_time(start_at), unix_time(end_at)]
|
||||
end
|
||||
|
||||
def read_value(object, field)
|
||||
return nil if object.blank?
|
||||
return nil unless object.respond_to?(field)
|
||||
|
||||
object.public_send(field)
|
||||
end
|
||||
|
||||
def unix_time(value)
|
||||
return nil if value.blank?
|
||||
|
||||
Time.zone.at(value.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
36
backend/app/services/billing/stripe/sync_payments.rb
Normal file
36
backend/app/services/billing/stripe/sync_payments.rb
Normal file
@@ -0,0 +1,36 @@
|
||||
module Billing
|
||||
module Stripe
|
||||
class SyncPayments
|
||||
def self.call(club:)
|
||||
new(club: club).call
|
||||
end
|
||||
|
||||
def initialize(club:)
|
||||
@club = club
|
||||
end
|
||||
|
||||
def call
|
||||
customer_id = @club.subscription&.stripe_customer_id
|
||||
return 0 if customer_id.blank?
|
||||
|
||||
count = 0
|
||||
starting_after = nil
|
||||
|
||||
loop do
|
||||
list = ::Stripe::Invoice.list(customer: customer_id, limit: 25, starting_after: starting_after)
|
||||
list.data.each do |invoice|
|
||||
next unless invoice.status == "paid"
|
||||
|
||||
RecordPayment.call(stripe_invoice: invoice, club: @club)
|
||||
count += 1
|
||||
end
|
||||
break unless list.has_more
|
||||
|
||||
starting_after = list.data.last.id
|
||||
end
|
||||
|
||||
count
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -19,6 +19,8 @@ module Billing
|
||||
downgrade_to_free(@event.data.object)
|
||||
when "invoice.payment_failed"
|
||||
mark_past_due(@event.data.object)
|
||||
when "invoice.paid"
|
||||
record_payment(@event.data.object)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -49,8 +51,7 @@ module Billing
|
||||
plan_slug = "premium_full" if plan_slug == "premium"
|
||||
plan = Plan[plan_slug]
|
||||
status = map_status(stripe_sub.status)
|
||||
period_start = unix_time(stripe_sub.current_period_start)
|
||||
period_end = unix_time(stripe_sub.current_period_end)
|
||||
period_start, period_end = SubscriptionPeriod.times(stripe_sub)
|
||||
|
||||
Billing::AssignPlan.call(
|
||||
club: club,
|
||||
@@ -84,6 +85,10 @@ module Billing
|
||||
)
|
||||
end
|
||||
|
||||
def record_payment(invoice)
|
||||
Billing::Stripe::RecordPayment.call(stripe_invoice: invoice)
|
||||
end
|
||||
|
||||
def mark_past_due(invoice)
|
||||
return if invoice.subscription.blank?
|
||||
|
||||
|
||||
110
backend/app/services/scoring/apply_action.rb
Normal file
110
backend/app/services/scoring/apply_action.rb
Normal file
@@ -0,0 +1,110 @@
|
||||
module Scoring
|
||||
class ApplyAction
|
||||
Result = Struct.new(:score, :set_won, :match_won, :winner, keyword_init: true)
|
||||
|
||||
def initialize(session:, action:)
|
||||
@session = session
|
||||
@match = session.match
|
||||
@action = action.to_s
|
||||
@rules = Rules.from_match(@match)
|
||||
end
|
||||
|
||||
def call
|
||||
score = @session.score_state || @session.create_score_state!(
|
||||
home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1, set_partials: []
|
||||
)
|
||||
|
||||
case @action
|
||||
when "home_point"
|
||||
apply_point(score, :home)
|
||||
when "away_point"
|
||||
apply_point(score, :away)
|
||||
when "home_undo"
|
||||
apply_undo(score, :home)
|
||||
when "away_undo"
|
||||
apply_undo(score, :away)
|
||||
when "close_set"
|
||||
apply_close_set(score)
|
||||
else
|
||||
raise ArgumentError, "Azione non valida"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_point(score, side)
|
||||
if side == :home
|
||||
score.home_points += 1
|
||||
else
|
||||
score.away_points += 1
|
||||
end
|
||||
score.save!
|
||||
broadcast(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
Result.new(score: score, set_won: winner.present?, match_won: false, winner: winner)
|
||||
end
|
||||
|
||||
def apply_undo(score, side)
|
||||
if side == :home
|
||||
score.home_points = [score.home_points - 1, 0].max
|
||||
else
|
||||
score.away_points = [score.away_points - 1, 0].max
|
||||
end
|
||||
score.save!
|
||||
broadcast(score)
|
||||
Result.new(score: score, set_won: false, match_won: false, winner: nil)
|
||||
end
|
||||
|
||||
def apply_close_set(score)
|
||||
winner = @rules.set_winner_from_points(
|
||||
home_points: score.home_points,
|
||||
away_points: score.away_points,
|
||||
current_set: score.current_set
|
||||
)
|
||||
winner ||= score.home_points > score.away_points ? :home : :away if score.home_points != score.away_points
|
||||
return Result.new(score: score, set_won: false, match_won: false, winner: nil) unless winner
|
||||
|
||||
close_set_for(score, winner)
|
||||
end
|
||||
|
||||
def close_set_for(score, winner)
|
||||
partials = Array(score.set_partials)
|
||||
partials << {
|
||||
"set" => score.current_set,
|
||||
"home" => score.home_points,
|
||||
"away" => score.away_points
|
||||
}
|
||||
|
||||
if winner == :home
|
||||
score.home_sets += 1
|
||||
else
|
||||
score.away_sets += 1
|
||||
end
|
||||
|
||||
match_winner = @rules.match_winner(home_sets: score.home_sets, away_sets: score.away_sets)
|
||||
|
||||
score.assign_attributes(
|
||||
set_partials: partials,
|
||||
home_points: 0,
|
||||
away_points: 0,
|
||||
current_set: score.current_set + 1
|
||||
)
|
||||
score.save!
|
||||
broadcast(score)
|
||||
Result.new(
|
||||
score: score,
|
||||
set_won: true,
|
||||
match_won: match_winner.present?,
|
||||
winner: match_winner || winner
|
||||
)
|
||||
end
|
||||
|
||||
def broadcast(score)
|
||||
SessionChannel.broadcast_message(@session, score.as_cable_payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
46
backend/app/services/scoring/rules.rb
Normal file
46
backend/app/services/scoring/rules.rb
Normal file
@@ -0,0 +1,46 @@
|
||||
module Scoring
|
||||
class Rules
|
||||
Side = Struct.new(:home, :away, keyword_init: true)
|
||||
|
||||
def self.from_match(match)
|
||||
overrides = match.scoring_rules.is_a?(Hash) ? match.scoring_rules : {}
|
||||
new(
|
||||
sets_to_win: match.sets_to_win,
|
||||
points_per_set: overrides["points_per_set"] || overrides[:points_per_set] || 25,
|
||||
points_deciding_set: overrides["points_deciding_set"] || overrides[:points_deciding_set] || 15,
|
||||
min_point_lead: overrides["min_point_lead"] || overrides[:min_point_lead] || 2
|
||||
)
|
||||
end
|
||||
|
||||
def initialize(sets_to_win:, points_per_set: 25, points_deciding_set: 15, min_point_lead: 2)
|
||||
@sets_to_win = sets_to_win
|
||||
@points_per_set = points_per_set
|
||||
@points_deciding_set = points_deciding_set
|
||||
@min_point_lead = min_point_lead
|
||||
end
|
||||
|
||||
def max_sets
|
||||
(@sets_to_win * 2) - 1
|
||||
end
|
||||
|
||||
def points_target(current_set)
|
||||
current_set >= max_sets ? @points_deciding_set : @points_per_set
|
||||
end
|
||||
|
||||
def set_winner_from_points(home_points:, away_points:, current_set:)
|
||||
target = points_target(current_set)
|
||||
if home_points >= target && (home_points - away_points) >= @min_point_lead
|
||||
:home
|
||||
elsif away_points >= target && (away_points - home_points) >= @min_point_lead
|
||||
:away
|
||||
end
|
||||
end
|
||||
|
||||
def match_winner(home_sets:, away_sets:)
|
||||
return :home if home_sets >= @sets_to_win
|
||||
return :away if away_sets >= @sets_to_win
|
||||
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
65
backend/app/services/sessions/regia_access.rb
Normal file
65
backend/app/services/sessions/regia_access.rb
Normal file
@@ -0,0 +1,65 @@
|
||||
module Sessions
|
||||
class RegiaAccess
|
||||
DEFAULT_TTL = 12.hours
|
||||
|
||||
def initialize(session)
|
||||
@session = session
|
||||
end
|
||||
|
||||
# Genera un nuovo link (invalida quello precedente).
|
||||
def issue_token!
|
||||
rotate!
|
||||
end
|
||||
|
||||
def rotate!
|
||||
token = SecureRandom.urlsafe_base64(24)
|
||||
expires = [DEFAULT_TTL.from_now, session_end_cap].compact.min
|
||||
@session.update!(
|
||||
regia_token_digest: digest(token),
|
||||
regia_token_expires_at: expires
|
||||
)
|
||||
token
|
||||
end
|
||||
|
||||
def url(token)
|
||||
"#{MatchLiveTv.app_public_url.chomp('/')}/regia/#{token}"
|
||||
end
|
||||
|
||||
def self.find_session_by_token(token)
|
||||
return if token.blank?
|
||||
|
||||
session = StreamSession.find_by(regia_token_digest: digest(token))
|
||||
return unless session
|
||||
return if session.regia_token_expires_at.blank? || session.regia_token_expires_at.past?
|
||||
return if session.terminal?
|
||||
|
||||
session
|
||||
end
|
||||
|
||||
def self.revoke!(session)
|
||||
session.update!(regia_token_digest: nil, regia_token_expires_at: nil)
|
||||
end
|
||||
|
||||
def self.digest(token)
|
||||
Digest::SHA256.hexdigest(token.to_s)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_valid?
|
||||
@session.regia_token_digest.present? &&
|
||||
@session.regia_token_expires_at&.future? &&
|
||||
!@session.terminal?
|
||||
end
|
||||
|
||||
def session_end_cap
|
||||
return nil unless @session.started_at
|
||||
|
||||
@session.started_at + 8.hours
|
||||
end
|
||||
|
||||
def digest(token)
|
||||
self.class.digest(token)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ module Sessions
|
||||
|
||||
def call
|
||||
cancel_timeout_job
|
||||
Sessions::RegiaAccess.revoke!(@session)
|
||||
@session.end_stream!
|
||||
Youtube::BroadcastService.new(@session.match.team).complete_broadcast!(@session.youtube_broadcast_id)
|
||||
Recordings::IndexSession.new(@session).call
|
||||
|
||||
@@ -48,11 +48,15 @@ module Teams
|
||||
end
|
||||
|
||||
def staff_count
|
||||
staff_count_for("transmission") + staff_count_for("regia")
|
||||
staff_count_for("transmission")
|
||||
end
|
||||
|
||||
def staff_count_for(kind)
|
||||
members_count_for(kind) + pending_invitations_count_for(kind)
|
||||
def staff_count_for(_kind = "transmission")
|
||||
staff_coverage.covered_count
|
||||
end
|
||||
|
||||
def staff_assigned_for(_kind = "transmission")
|
||||
staff_coverage.assigned_count
|
||||
end
|
||||
|
||||
def members_count
|
||||
@@ -149,29 +153,19 @@ module Teams
|
||||
def assert_can_invite!(staff_kind: "transmission")
|
||||
return unless active?
|
||||
|
||||
kind = staff_kind.to_s
|
||||
unless TeamInvitation::STAFF_KINDS.include?(kind)
|
||||
raise EntitlementError.new("Ruolo staff non valido", code: "invalid_staff_kind")
|
||||
end
|
||||
|
||||
limit = staff_limit_for(kind)
|
||||
limit = max_staff_transmission
|
||||
return if limit.nil?
|
||||
|
||||
used = staff_count_for(kind)
|
||||
used = staff_assigned_for
|
||||
return if used < limit
|
||||
|
||||
label = kind == "regia" ? "regia" : "trasmissione"
|
||||
raise EntitlementError.new(
|
||||
"Limite staff #{label} raggiunto (#{limit} all'anno). Passa a un piano superiore.",
|
||||
"Limite responsabili trasmissione raggiunto (#{limit} all'anno). Passa a un piano superiore.",
|
||||
code: "staff_limit_reached",
|
||||
billing_url: billing_url
|
||||
)
|
||||
end
|
||||
|
||||
def staff_limit_for(kind)
|
||||
kind == "regia" ? max_staff_regia : max_staff_transmission
|
||||
end
|
||||
|
||||
def assert_can_connect_youtube!
|
||||
unless premium_full?
|
||||
raise EntitlementError.new(
|
||||
@@ -216,10 +210,8 @@ module Teams
|
||||
staff_manage_url: staff_manage_url,
|
||||
max_staff: max_staff,
|
||||
max_staff_transmission: max_staff_transmission,
|
||||
max_staff_regia: max_staff_regia,
|
||||
staff_used: staff_count,
|
||||
staff_transmission_used: staff_count_for("transmission"),
|
||||
staff_regia_used: staff_count_for("regia"),
|
||||
concurrent_streams_used: concurrent_streams_used,
|
||||
concurrent_streams_limit: concurrent_streams_limit,
|
||||
recordings_enabled: can_access_recordings?,
|
||||
@@ -230,6 +222,10 @@ module Teams
|
||||
}
|
||||
end
|
||||
|
||||
def staff_coverage
|
||||
@staff_coverage ||= StaffCoverage.new(@team)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_free_subscription!
|
||||
|
||||
@@ -9,77 +9,55 @@ module Teams
|
||||
end
|
||||
|
||||
class StaffAssignment
|
||||
def self.call(team:, user:, staff_kind:, membership: nil)
|
||||
def self.call(team:, user:, staff_kind: "transmission", membership: nil)
|
||||
new(team: team, user: user, staff_kind: staff_kind, membership: membership).call
|
||||
end
|
||||
|
||||
def initialize(team:, user:, staff_kind:, membership: nil)
|
||||
def initialize(team:, user:, staff_kind: "transmission", membership: nil)
|
||||
@team = team
|
||||
@user = user
|
||||
@staff_kind = staff_kind.to_s
|
||||
@staff_kind = "transmission"
|
||||
@membership = membership
|
||||
end
|
||||
|
||||
def call
|
||||
unless TeamInvitation::STAFF_KINDS.include?(@staff_kind)
|
||||
raise StaffAssignmentError, "Ruolo staff non valido"
|
||||
end
|
||||
|
||||
StaffEmailValidator.assert_available!(team: @team, email: @user.email, staff_kind: @staff_kind, except_user: @user)
|
||||
StaffEmailValidator.assert_available!(
|
||||
team: @team, email: @user.email, except_user: @user
|
||||
)
|
||||
|
||||
ut = @membership || @user.user_teams.find_by!(team: @team)
|
||||
return ut if ut.staff_kind == @staff_kind
|
||||
return ut if ut.staff_kind == "transmission"
|
||||
|
||||
ent = Entitlements.new(@team)
|
||||
ent.assert_can_invite!(staff_kind: @staff_kind)
|
||||
ut.update!(staff_kind: @staff_kind)
|
||||
Entitlements.new(@team).assert_can_invite!
|
||||
ut.update!(staff_kind: "transmission")
|
||||
ut
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
class StaffEmailValidator
|
||||
def self.assert_available!(team:, email:, staff_kind:, except_user: nil)
|
||||
new(team: team, email: email, staff_kind: staff_kind, except_user: except_user).assert_available!
|
||||
def self.assert_available!(team:, email:, except_user: nil, staff_kind: nil)
|
||||
new(team: team, email: email, except_user: except_user).assert_available!
|
||||
end
|
||||
|
||||
def initialize(team:, email:, staff_kind:, except_user: nil)
|
||||
def initialize(team:, email:, except_user: nil)
|
||||
@team = team
|
||||
@email = email.to_s.downcase.strip
|
||||
@staff_kind = staff_kind.to_s
|
||||
@except_user = except_user
|
||||
end
|
||||
|
||||
def assert_available!
|
||||
if @except_user
|
||||
ut = @team.user_teams.find_by(user: @except_user)
|
||||
if ut&.staff_kind.present? && ut.staff_kind != @staff_kind
|
||||
other_label = ut.staff_kind == "regia" ? "regia" : "trasmissione"
|
||||
raise StaffAssignmentError,
|
||||
"Questo account è già staff #{other_label}. Trasmissione e regia devono essere email diverse."
|
||||
end
|
||||
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where.not(staff_kind: nil)
|
||||
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
|
||||
if user_scope.exists?
|
||||
raise StaffAssignmentError,
|
||||
"L'email #{@email} è già assegnata come responsabile trasmissione per questa squadra."
|
||||
end
|
||||
|
||||
conflict = conflicting_assignment
|
||||
return unless conflict
|
||||
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email)
|
||||
return unless inv.exists?
|
||||
|
||||
other_label = conflict[:kind] == "regia" ? "regia" : "trasmissione"
|
||||
raise StaffAssignmentError,
|
||||
"L'email #{@email} è già assegnata come staff #{other_label}. Trasmissione e regia devono essere persone (email) diverse."
|
||||
end
|
||||
|
||||
def conflicting_assignment
|
||||
other_kind = @staff_kind == "transmission" ? "regia" : "transmission"
|
||||
|
||||
user_scope = @team.user_teams.joins(:user).where(users: { email: @email }).where(staff_kind: other_kind)
|
||||
user_scope = user_scope.where.not(user_id: @except_user.id) if @except_user
|
||||
|
||||
return { kind: other_kind, source: :member } if user_scope.exists?
|
||||
|
||||
inv = @team.team_invitations.pending.where("LOWER(email) = ?", @email).where(staff_kind: other_kind)
|
||||
return { kind: other_kind, source: :invitation } if inv.exists?
|
||||
|
||||
nil
|
||||
"Esiste già un invito in sospeso per #{@email}."
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
35
backend/app/services/teams/staff_coverage.rb
Normal file
35
backend/app/services/teams/staff_coverage.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
module Teams
|
||||
class StaffCoverage
|
||||
def initialize(team)
|
||||
@team = team
|
||||
end
|
||||
|
||||
def assigned_count(_kind = "transmission")
|
||||
members_count + pending_invitations_count
|
||||
end
|
||||
|
||||
def covered_count(_kind = "transmission")
|
||||
assigned_count.positive? ? 1 : 0
|
||||
end
|
||||
|
||||
def role_label_for(membership)
|
||||
return "Trasmissione" if membership.staff_kind.present?
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def open_slot_for_invite?(_kind = "transmission")
|
||||
assigned_count < 1
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def members_count
|
||||
@team.user_teams.where.not(staff_kind: nil).count
|
||||
end
|
||||
|
||||
def pending_invitations_count
|
||||
@team.team_invitations.pending.count
|
||||
end
|
||||
end
|
||||
end
|
||||
23
backend/app/views/admin/billing_invoices/index.html.erb
Normal file
23
backend/app/views/admin/billing_invoices/index.html.erb
Normal file
@@ -0,0 +1,23 @@
|
||||
<h1>Fatture — <%= @club.name %></h1>
|
||||
<p><%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %></p>
|
||||
|
||||
<% if @invoices.any? %>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr><th>Numero</th><th>Data</th><th>Importo</th><th>Stato</th><th>PDF</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @invoices.each do |inv| %>
|
||||
<tr>
|
||||
<td><%= inv.number %></td>
|
||||
<td><%= inv.issued_on %></td>
|
||||
<td><%= inv.formatted_amount %></td>
|
||||
<td><%= inv.status %></td>
|
||||
<td><%= inv.pdf.attached? ? "Sì" : "No" %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>Nessuna fattura.</p>
|
||||
<% end %>
|
||||
36
backend/app/views/admin/billing_invoices/new.html.erb
Normal file
36
backend/app/views/admin/billing_invoices/new.html.erb
Normal file
@@ -0,0 +1,36 @@
|
||||
<h1>Nuova fattura Aruba — <%= @club.name %></h1>
|
||||
<p style="color:#666">Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.</p>
|
||||
|
||||
<div class="card">
|
||||
<%= form_with model: @invoice, url: admin_club_billing_invoices_path(@club), multipart: true do |f| %>
|
||||
<%= f.label :number, "Numero fattura" %>
|
||||
<%= f.text_field :number, required: true %>
|
||||
|
||||
<%= f.label :issued_on, "Data emissione" %>
|
||||
<%= f.date_field :issued_on, required: true %>
|
||||
|
||||
<%= label_tag :amount_euros, "Importo (€)" %>
|
||||
<%= number_field_tag "billing_invoice[amount_euros]", nil, step: 0.01, min: 0.01, required: true %>
|
||||
|
||||
<%= f.label :status, "Stato" %>
|
||||
<%= f.select :status, Billing::Invoice::STATUSES %>
|
||||
|
||||
<%= f.label :billing_payment_id, "Pagamento collegato (opzionale)" %>
|
||||
<%= f.collection_select :billing_payment_id, @club.billing_payments.recent, :id,
|
||||
->(p) { "#{p.paid_at&.to_date || p.created_at.to_date} — #{p.formatted_amount}" },
|
||||
{ include_blank: "Nessuno" } %>
|
||||
|
||||
<%= f.label :aruba_document_id, "ID documento Aruba (opzionale)" %>
|
||||
<%= f.text_field :aruba_document_id %>
|
||||
|
||||
<%= f.label :pdf, "PDF fattura" %>
|
||||
<%= f.file_field :pdf, accept: "application/pdf", required: true %>
|
||||
|
||||
<%= f.label :notes, "Note interne" %>
|
||||
<%= f.text_area :notes, rows: 2 %>
|
||||
|
||||
<%= f.submit "Registra fattura", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p><%= link_to "← Elenco fatture", admin_club_billing_invoices_path(@club) %></p>
|
||||
@@ -99,7 +99,10 @@
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></td>
|
||||
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
|
||||
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || "—" %></td>
|
||||
<td><%= link_to "Dettaglio", admin_session_path(s) %></td>
|
||||
<td class="admin-actions">
|
||||
<%= link_to "Dettaglio", admin_session_path(s) %>
|
||||
<%= button_to "Termina", stop_admin_session_path(s), method: :post, class: "admin-btn admin-btn--danger admin-btn--sm", form: { data: { turbo_confirm: "Terminare questa sessione?" } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
<h2>Session <%= @session.id %></h2>
|
||||
<p>Status: <strong><%= @session.status %></strong></p>
|
||||
<% unless @session.terminal? %>
|
||||
<p>
|
||||
<%= button_to "Termina sessione",
|
||||
stop_admin_session_path(@session),
|
||||
method: :post,
|
||||
class: "admin-btn admin-btn--danger",
|
||||
form: { data: { turbo_confirm: "Terminare la trasmissione? Il path RTMP verrà rimosso e lo stato passerà a ended." } } %>
|
||||
</p>
|
||||
<% end %>
|
||||
<p>Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %></p>
|
||||
<% if @session.youtube_broadcast_id %>
|
||||
<p>YouTube: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank">Broadcast</a></p>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<h2><%= @team.name %></h2>
|
||||
<p>Sport: <%= @team.sport %></p>
|
||||
<% if @team.club %>
|
||||
<p>Società: <%= @team.club.name %> · <%= link_to "Fatture società", admin_club_billing_invoices_path(@team.club) %></p>
|
||||
<% end %>
|
||||
<p>YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %></p>
|
||||
|
||||
<h3>Partite</h3>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=21">
|
||||
<link rel="stylesheet" href="/marketing.css?v=31">
|
||||
</head>
|
||||
<body>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
@@ -16,5 +16,8 @@
|
||||
<%= yield %>
|
||||
</main>
|
||||
<%= render "shared/marketing_footer" %>
|
||||
<script src="/branding-form.js?v=1" defer></script>
|
||||
<script src="/roster-form.js?v=1" defer></script>
|
||||
<script src="/password-toggle.js?v=1" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=21">
|
||||
<link rel="stylesheet" href="/live.css?v=2">
|
||||
<link rel="stylesheet" href="/marketing.css?v=30">
|
||||
<link rel="stylesheet" href="/live.css?v=3">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
<% if current_user.primary_club %>
|
||||
· <%= link_to "Società", public_club_path(current_user.primary_club) %>
|
||||
<% elsif current_user.manageable_teams.any? %>
|
||||
· <%= link_to "Dashboard", public_team_dashboard_path(current_user.manageable_teams.first) %>
|
||||
· <%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first) %>
|
||||
<% end %>
|
||||
· <%= button_to "Esci", public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||
<% else %>
|
||||
|
||||
16
backend/app/views/layouts/regia.html.erb
Normal file
16
backend/app/views/layouts/regia.html.erb
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="it">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="theme-color" content="#0a0a0e">
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Regia — Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="/regia.css?v=1">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body class="regia-body">
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
18
backend/app/views/public/club_billing/profile.html.erb
Normal file
18
backend/app/views/public/club_billing/profile.html.erb
Normal file
@@ -0,0 +1,18 @@
|
||||
<% content_for :title, "Dati di fatturazione — #{@club.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:640px">
|
||||
<h1>Dati di fatturazione</h1>
|
||||
<p style="color:#aaa">Società: <strong><%= @club.name %></strong>. Inserisci i dati per l’intestazione delle fatture e l’invio tramite SDI o PEC.</p>
|
||||
|
||||
<div class="card">
|
||||
<%= form_with url: public_club_billing_profile_path(@club), method: :patch do %>
|
||||
<%= render "shared/billing_profile_fields", record: @club %>
|
||||
<%= submit_tag "Salva dati di fatturazione", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p style="margin-top:20px">
|
||||
<%= link_to "← Abbonamento", public_club_billing_path(@club) %>
|
||||
</p>
|
||||
</div>
|
||||
35
backend/app/views/public/club_matches/new.html.erb
Normal file
35
backend/app/views/public/club_matches/new.html.erb
Normal file
@@ -0,0 +1,35 @@
|
||||
<% content_for :title, "Programma partita — #{@club.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<p class="results-hint" style="margin-bottom:8px">
|
||||
<%= link_to "← Dirette e partite", public_live_index_path(club_id: @club.id), class: "back-link" %>
|
||||
</p>
|
||||
<h1>Programma partita</h1>
|
||||
<p style="color:#888">
|
||||
<%= @club.name %>
|
||||
<% if @club_admin %>
|
||||
· Puoi scegliere qualsiasi squadra della società
|
||||
<% else %>
|
||||
· Solo le squadre in cui sei responsabile trasmissione
|
||||
<% end %>
|
||||
</p>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<%= form_with url: public_club_matches_path(@club), method: :post, local: true do %>
|
||||
<%= label_tag :team_id, "Squadra" %>
|
||||
<% if @schedulable_teams.size == 1 %>
|
||||
<%= hidden_field_tag :team_id, @selected_team.id %>
|
||||
<p style="margin:0 0 16px;font-weight:600"><%= @selected_team.name %></p>
|
||||
<% else %>
|
||||
<%= select_tag :team_id,
|
||||
options_from_collection_for_select(@schedulable_teams, :id, :name, @selected_team&.id),
|
||||
required: true %>
|
||||
<% end %>
|
||||
|
||||
<%= render "public/matches/schedule_fields", match: @match %>
|
||||
|
||||
<%= submit_tag "Salva in programma", class: "btn btn-primary", style: "margin-top:20px" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -3,44 +3,12 @@
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Abbonamento</h1>
|
||||
<p>Società: <strong><%= @club.name %></strong> · Piano attuale: <strong><%= @entitlements.plan.name %></strong> (valido per tutte le squadre)</p>
|
||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription %>
|
||||
|
||||
<div class="plans">
|
||||
<% @plans.each do |plan| %>
|
||||
<div class="plan-card <%= 'featured' if plan.slug == @entitlements.plan.slug %>">
|
||||
<h3><%= plan.name %></h3>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
<div class="plan-price" style="color:#e53935">€40/anno</div>
|
||||
<p style="color:#888;font-size:0.85rem">oppure €5/mese</p>
|
||||
<% else %>
|
||||
<div class="plan-price">€200/anno</div>
|
||||
<p style="color:#888;font-size:0.85rem">oppure €20/mese</p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<li>Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno per squadra</li>
|
||||
<li>Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno per squadra</li>
|
||||
<li>Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea (tutta la società)</li>
|
||||
<li>Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %></li>
|
||||
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||
</ul>
|
||||
<% if plan.slug == @entitlements.plan.slug %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||
<%= link_to "Attiva #{plan.name}", public_club_checkout_path(@club, plan: plan.slug), class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<p style="color:#888">Stripe non configurato</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= render "shared/stripe_secure_payment" %>
|
||||
<%= render "shared/plan_cards", show_stripe_portal: true %>
|
||||
|
||||
<% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %>
|
||||
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_club_portal_path(@club), class: "btn btn-secondary" %></p>
|
||||
<% end %>
|
||||
<%= render "shared/billing_documents", club: @club, payments: @payments, invoices: @invoices %>
|
||||
|
||||
<p><%= link_to "← Società", public_club_path(@club) %></p>
|
||||
<p style="margin-top:24px"><%= link_to "← Società", public_club_path(@club) %></p>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
<%= label_tag "club[sport]", "Sport principale" %>
|
||||
<%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @club.sport) %>
|
||||
<%= render "shared/branding_fields", record: @club, legend: "Logo e colori societari" %>
|
||||
<%= render "shared/billing_profile_fields", record: @club %>
|
||||
<%= submit_tag "Salva", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<%= label_tag "club[sport]", "Sport principale" %>
|
||||
<%= select_tag "club[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], params.dig(:club, :sport) || "volleyball") %>
|
||||
<%= render "shared/branding_fields", record: Club.new(primary_color: "#e53935", secondary_color: "#ffffff"), legend: "Logo e colori societari" %>
|
||||
<%= render "shared/billing_profile_fields", record: Club.new(billing_country: "IT") %>
|
||||
|
||||
<h2 class="form-section-title" style="margin-top:24px">Prima squadra</h2>
|
||||
<%= label_tag "first_team[name]", "Nome squadra" %>
|
||||
@@ -20,10 +21,11 @@
|
||||
|
||||
<%= label_tag :plan, "Piano iniziale (per tutta la società)" %>
|
||||
<%= select_tag :plan, options_for_select([
|
||||
["Free — 1 staff trasmissione + 1 regia per squadra, 1 live", "free"],
|
||||
["Free — 1 responsabile trasmissione per squadra, 1 live", "free"],
|
||||
["Premium Light — €40/anno o €5/mese", "premium_light"],
|
||||
["Premium Full — €200/anno o €20/mese", "premium_full"]
|
||||
], params[:plan] || "free") %>
|
||||
<%= render "shared/stripe_secure_payment", compact: true %>
|
||||
<%= submit_tag "Crea società", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<%= link_to "Modifica società", public_edit_club_path(@club), class: "btn btn-secondary" %>
|
||||
<%= link_to "Abbonamento", public_club_billing_path(@club), class: "btn btn-primary" %>
|
||||
<%= link_to "Nuova squadra", public_new_club_team_path(@club), class: "btn btn-secondary" %>
|
||||
<%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
|
||||
<%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<h2>Squadre</h2>
|
||||
@@ -44,7 +44,9 @@
|
||||
<% end %>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<%= link_to "Dashboard", public_team_dashboard_path(team) %>
|
||||
<%= link_to "Dettagli", public_team_details_path(team) %>
|
||||
·
|
||||
<%= link_to "Partite", public_team_matches_path(team) %>
|
||||
·
|
||||
<%= link_to "Modifica", public_edit_team_path(team) %>
|
||||
</td>
|
||||
@@ -57,5 +59,5 @@
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<p style="color:#888;margin-top:24px">Ogni squadra ha il proprio staff trasmissione/regia. L’abbonamento vale per tutta la società.</p>
|
||||
<p style="color:#888;margin-top:24px">Ogni squadra ha i propri responsabili trasmissione. Il punteggio da un altro telefono usa il link regia (senza account). L’abbonamento vale per tutta la società.</p>
|
||||
</div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
||||
<p>Sei stato aggiunto allo staff come <strong><%= @invitation.staff_kind == "regia" ? "Regia" : "Trasmissione" %></strong> (<%= @invitation.email %>).</p>
|
||||
<p>Sei stato aggiunto come <strong>responsabile trasmissione</strong> (<%= @invitation.email %>).</p>
|
||||
<% if logged_in? %>
|
||||
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
|
||||
@@ -2,30 +2,50 @@
|
||||
<% content_for :meta_description, "Guarda le dirette live delle partite giovanili ora in corso e le prossime gare programmate. Cerca la tua squadra e apri il link senza installare app." %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_live_index_path) %>
|
||||
|
||||
<% live_index_params = @club ? { club_id: @club.id } : {} %>
|
||||
<div class="wrap">
|
||||
<h1>Dirette e partite in programma</h1>
|
||||
<p class="results-hint">
|
||||
Cerca la tua squadra: trovi le dirette attive e le partite già programmate dall’app.
|
||||
</p>
|
||||
<% if @club %>
|
||||
<p class="results-hint" style="margin-bottom:8px">
|
||||
<%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %>
|
||||
</p>
|
||||
<h1>Dirette e partite — <%= @club.name %></h1>
|
||||
<p class="results-hint">
|
||||
Solo le squadre di questa società: dirette in corso e partite programmate da app o sito.
|
||||
</p>
|
||||
<% else %>
|
||||
<h1>Dirette e partite in programma</h1>
|
||||
<p class="results-hint">
|
||||
Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if @club && @can_schedule_match %>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">
|
||||
<%= link_to "Programma partita", public_new_club_match_path(@club), class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<%= form_with url: public_live_index_path, method: :get, local: true, class: "search-form" do %>
|
||||
<% if @club %>
|
||||
<%= hidden_field_tag :club_id, @club.id %>
|
||||
<% end %>
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="<%= @query %>"
|
||||
placeholder="Es. Tigers, Eagles, nome squadra…"
|
||||
placeholder="<%= @club ? "Cerca società, squadra, avversario o luogo…" : "Es. Crazy Volley, Serie D, avversario…" %>"
|
||||
aria-label="Cerca squadra"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary">Cerca</button>
|
||||
<% if @query.present? %>
|
||||
<%= link_to "Azzera", public_live_index_path, class: "btn btn-secondary" %>
|
||||
<%= link_to "Azzera", public_live_index_path(live_index_params), class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<% if @query.present? %>
|
||||
<p class="results-hint">
|
||||
Risultati per «<%= h @query %>»:
|
||||
Risultati per «<%= h @query %>»<%= @club ? " in #{@club.name}" : "" %>:
|
||||
<%= @sessions.size %> in diretta · <%= @upcoming_matches.size %> in programma
|
||||
</p>
|
||||
<% end %>
|
||||
@@ -37,7 +57,7 @@
|
||||
<% match = session.match %>
|
||||
<% on_air = @online_paths.include?(session.mediamtx_path_name) %>
|
||||
<article class="live-card">
|
||||
<h3><%= match.team.name %> vs <%= match.opponent_name %></h3>
|
||||
<%= live_match_card_heading(match) %>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
<%= session.platform == "matchlivetv" ? "Match Live TV" : session.platform.capitalize %>
|
||||
@@ -70,11 +90,11 @@
|
||||
|
||||
<% if @upcoming_matches.any? %>
|
||||
<h2 class="section-heading<%= ' section-heading--spaced' if @sessions.any? %>">Prossime partite</h2>
|
||||
<p class="results-hint">Programmate dall’app: la diretta partirà quando lo staff avvierà la trasmissione.</p>
|
||||
<p class="results-hint">Programmate da app o sito: la diretta partirà quando lo staff avvierà la trasmissione.</p>
|
||||
<div class="live-grid upcoming-grid">
|
||||
<% @upcoming_matches.each do |match| %>
|
||||
<article class="live-card live-card--upcoming">
|
||||
<h3><%= match.team.name %> vs <%= match.opponent_name %></h3>
|
||||
<%= live_match_card_heading(match) %>
|
||||
<p class="meta">
|
||||
<% if match.location.present? %><%= match.location %> · <% end %>
|
||||
<%= match.category.presence || match.sport&.capitalize %>
|
||||
@@ -96,6 +116,25 @@
|
||||
<p><strong>Nessuna diretta in questo momento.</strong></p>
|
||||
<p>Torna all’orario indicato: quando la squadra avvierà lo streaming, la partita comparirà in «In diretta adesso».</p>
|
||||
</div>
|
||||
<% elsif @club %>
|
||||
<div class="empty-state">
|
||||
<p><strong>Nessuna diretta o partita in programma per <%= @club.name %>.</strong></p>
|
||||
<p>
|
||||
<% if @can_schedule_match %>
|
||||
<%= link_to "Programma la prima partita", public_new_club_match_path(@club), class: "btn btn-primary" %>
|
||||
oppure dall’app Match Live TV.
|
||||
<% elsif @club.teams.any? %>
|
||||
Chiedi al titolare della società o a un responsabile trasmissione di programmare una gara.
|
||||
<% else %>
|
||||
Aggiungi una squadra dalla
|
||||
<%= link_to "pagina società", public_club_path(@club) %>
|
||||
e programma la prima partita.
|
||||
<% end %>
|
||||
</p>
|
||||
<p style="margin-top:16px">
|
||||
<%= link_to "← Torna alla società", public_club_path(@club), class: "btn btn-secondary" %>
|
||||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="empty-hero">
|
||||
<div class="empty-hero-copy">
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<% content_for :title do %><%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
|
||||
<% content_for :meta_description do %>Segui in diretta live <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %>
|
||||
<% club_name = @match.team.club&.name %>
|
||||
<% content_for :title do %><%= club_name %> · <%= @match.team.name %> vs <%= @match.opponent_name %> — Diretta live<% end %>
|
||||
<% content_for :meta_description do %>Segui in diretta live <%= club_name %> — <%= @match.team.name %> vs <%= @match.opponent_name %><% if @match.location.present? %> — <%= @match.location %><% end %>. Guarda dal browser senza installare app.<% end %>
|
||||
<% content_for :robots, "noindex, follow" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
@@ -11,7 +12,7 @@
|
||||
<div class="wrap">
|
||||
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %>
|
||||
|
||||
<h1><%= @match.team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<%= live_match_page_heading(@match) %>
|
||||
<p>
|
||||
<% if @stream_closed %>
|
||||
<span id="status-badge" class="badge badge-ended">Diretta chiusa</span>
|
||||
|
||||
4
backend/app/views/public/matches/_form.html.erb
Normal file
4
backend/app/views/public/matches/_form.html.erb
Normal file
@@ -0,0 +1,4 @@
|
||||
<%= form_with model: @match, url: form_url, method: form_method do %>
|
||||
<%= render "schedule_fields", match: @match %>
|
||||
<%= submit_tag submit_label, class: "btn btn-primary", style: "margin-top:20px" %>
|
||||
<% end %>
|
||||
13
backend/app/views/public/matches/_schedule_fields.html.erb
Normal file
13
backend/app/views/public/matches/_schedule_fields.html.erb
Normal file
@@ -0,0 +1,13 @@
|
||||
<%= label_tag "match[opponent_name]", "Avversario" %>
|
||||
<%= text_field_tag "match[opponent_name]", match.opponent_name, required: true, placeholder: "es. Volley Milano" %>
|
||||
|
||||
<%= label_tag "match[location]", "Luogo (opzionale)" %>
|
||||
<%= text_field_tag "match[location]", match.location, placeholder: "Palestra, città" %>
|
||||
|
||||
<%= label_tag "match[scheduled_at]", "Data e ora" %>
|
||||
<% scheduled_value = match.scheduled_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||
<%= datetime_local_field_tag "match[scheduled_at]", scheduled_value, required: true %>
|
||||
|
||||
<p style="color:#888;font-size:0.88rem;margin:12px 0 0">
|
||||
La diretta non parte da sola: comparirà in app e sul sito. Avvierai lo streaming dal telefono quando sei in campo.
|
||||
</p>
|
||||
14
backend/app/views/public/matches/edit.html.erb
Normal file
14
backend/app/views/public/matches/edit.html.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
<% content_for :title, "Modifica partita — #{@team.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<h1>Modifica partita</h1>
|
||||
<p style="color:#888"><%= @team.name %> vs <strong><%= @match.opponent_name %></strong></p>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<%= render "form",
|
||||
form_url: public_team_match_path(@team, @match),
|
||||
form_method: :patch,
|
||||
submit_label: "Salva modifiche" %>
|
||||
</div>
|
||||
<p style="margin-top:16px"><%= link_to "← Elenco partite", public_team_matches_path(@team) %></p>
|
||||
</div>
|
||||
77
backend/app/views/public/matches/index.html.erb
Normal file
77
backend/app/views/public/matches/index.html.erb
Normal file
@@ -0,0 +1,77 @@
|
||||
<% content_for :title, "Partite — #{@team.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:720px">
|
||||
<nav class="team-dashboard-nav" aria-label="Percorso">
|
||||
<%= link_to "← #{@team.name}", public_team_details_path(@team), class: "team-dashboard-nav__club" %>
|
||||
</nav>
|
||||
|
||||
<header style="margin:16px 0 20px">
|
||||
<h1>Partite — <%= @team.name %></h1>
|
||||
<p style="color:#888;margin-top:8px">
|
||||
Programma qui da PC data, avversario e luogo. Quando sei in palestra apri l’app Match Live TV,
|
||||
seleziona questa squadra e avvia la diretta sulla partita.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:20px">
|
||||
<%= link_to "Programma partita", new_public_team_match_path(@team), class: "btn btn-primary" %>
|
||||
<%= link_to "Dettagli squadra", public_team_details_path(@team), class: "btn btn-secondary" %>
|
||||
<%= link_to "Dirette e programma", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<% if @matches.any? %>
|
||||
<table class="data">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Avversario</th>
|
||||
<th>Data e ora</th>
|
||||
<th>Luogo</th>
|
||||
<th>Stato</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @matches.each do |match| %>
|
||||
<tr>
|
||||
<td><strong><%= match.opponent_name %></strong></td>
|
||||
<td>
|
||||
<% if match.scheduled_at %>
|
||||
<%= l match.scheduled_at.in_time_zone, format: :long %>
|
||||
<% else %>
|
||||
<span style="color:#888">Senza orario</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td><%= match.location.presence || "—" %></td>
|
||||
<td>
|
||||
<% label = match.public_status_label %>
|
||||
<% if label == "In corso / da riprendere" %>
|
||||
<span style="color:#e53935;font-weight:600"><%= label %></span>
|
||||
<% else %>
|
||||
<%= label %>
|
||||
<% end %>
|
||||
</td>
|
||||
<td style="white-space:nowrap">
|
||||
<%= link_to "Modifica", edit_public_team_match_path(@team, match) %>
|
||||
<% if match.deletable? %>
|
||||
·
|
||||
<%= button_to "Elimina", public_team_match_path(@team, match),
|
||||
method: :delete,
|
||||
form: { data: { turbo_confirm: "Eliminare questa partita?" } },
|
||||
class: "btn btn-secondary",
|
||||
style: "padding:4px 10px;font-size:0.8rem;display:inline" %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888;margin:0">
|
||||
Nessuna partita in calendario.
|
||||
<%= link_to "Programma la prima", new_public_team_match_path(@team) %>.
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
14
backend/app/views/public/matches/new.html.erb
Normal file
14
backend/app/views/public/matches/new.html.erb
Normal file
@@ -0,0 +1,14 @@
|
||||
<% content_for :title, "Programma partita — #{@team.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<h1>Programma partita</h1>
|
||||
<p style="color:#888"><%= @club.name %> · <strong><%= @team.name %></strong></p>
|
||||
<div class="card" style="margin-top:16px">
|
||||
<%= render "form",
|
||||
form_url: public_team_matches_path(@team),
|
||||
form_method: :post,
|
||||
submit_label: "Salva in programma" %>
|
||||
</div>
|
||||
<p style="margin-top:16px"><%= link_to "← Elenco partite", public_team_matches_path(@team) %></p>
|
||||
</div>
|
||||
@@ -34,7 +34,7 @@
|
||||
|
||||
<h2>Pallavolo: punteggio e regole</h2>
|
||||
<p>
|
||||
Set da vincere 2 o 3, punti 25/21/15 configurabili per tornei non standard. La regia è separata dalla ripresa:
|
||||
Set da vincere 2 o 3, punti 25/21/15 configurabili per tornei non standard. Il punteggio si gestisce da un link condiviso (senza account):
|
||||
in campo resta concentrato sul gioco, non sul telefono.
|
||||
</p>
|
||||
|
||||
|
||||
@@ -3,60 +3,27 @@
|
||||
<% content_for :canonical_url, seo_absolute_url(public_prezzi_path) %>
|
||||
|
||||
<div class="wrap" style="padding-top:24px">
|
||||
<h1>Piani per la tua squadra</h1>
|
||||
<p style="color:#aaa">Abbonamento annuale per squadra: lo staff gestisce trasmissione e regia con credenziali dedicate. Pagamento sicuro su Stripe.</p>
|
||||
<% if @club %>
|
||||
<h1>Piani per la tua società</h1>
|
||||
<% if @entitlements %>
|
||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription %>
|
||||
<% else %>
|
||||
<p style="color:#aaa">Società: <strong><%= @club.name %></strong> · <%= link_to "Gestisci abbonamento", public_club_billing_path(@club) %></p>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<h1>Piani per la tua squadra</h1>
|
||||
<p style="color:#aaa">Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall’app; il punteggio si gestisce con un link condivisibile.</p>
|
||||
<% end %>
|
||||
<%= render "shared/stripe_secure_payment" %>
|
||||
|
||||
<div class="plans">
|
||||
<div class="plan-card">
|
||||
<h3>Free</h3>
|
||||
<div class="plan-price">€0</div>
|
||||
<ul>
|
||||
<li><strong>1</strong> persona staff / anno per la <strong>trasmissione</strong></li>
|
||||
<li><strong>1</strong> persona staff / anno per la <strong>regia</strong></li>
|
||||
<li><strong>1</strong> live alla volta su matchlivetv.eminux.it</li>
|
||||
<li>Punteggio live e regia da app</li>
|
||||
<li style="color:#888">No salvataggio / replay</li>
|
||||
<li style="color:#888">No YouTube</li>
|
||||
</ul>
|
||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<div class="plan-card featured">
|
||||
<h3>Premium Light</h3>
|
||||
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
|
||||
<ul>
|
||||
<li><strong>5</strong> persone staff / anno per la <strong>trasmissione</strong></li>
|
||||
<li><strong>5</strong> persone staff / anno per la <strong>regia</strong></li>
|
||||
<li>Fino a <strong>3</strong> partite in contemporanea</li>
|
||||
<li>Live su matchlivetv.eminux.it e sul <strong>canale YouTube Match TV Light</strong></li>
|
||||
<li>Archivio server <strong>30 giorni</strong> e download su telefono</li>
|
||||
</ul>
|
||||
<%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
|
||||
<div class="plan-card">
|
||||
<h3>Premium Full</h3>
|
||||
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
|
||||
<ul>
|
||||
<li>Staff <strong>illimitato</strong> / anno (trasmissione e regia)</li>
|
||||
<li>Fino a <strong>10</strong> partite in contemporanea</li>
|
||||
<li>Collegamento con il <strong>canale YouTube della società</strong></li>
|
||||
<li>Archivio server <strong>90 giorni</strong> e download su telefono</li>
|
||||
<li>Sito società (in arrivo)</li>
|
||||
</ul>
|
||||
<%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<%= render "shared/plan_cards" %>
|
||||
|
||||
<table class="compare-table">
|
||||
<thead>
|
||||
<tr><th></th><th>Free</th><th>Premium Light</th><th>Premium Full</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>Staff trasmissione / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
|
||||
<tr><td>Staff regia / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
|
||||
<tr><td>Responsabili trasmissione / anno</td><td>1</td><td>5</td><td>Illimitato</td></tr>
|
||||
<tr><td>Partite in contemporanea</td><td>1</td><td>3</td><td>10</td></tr>
|
||||
<tr><td>Live su Match Live TV</td><td>Sì</td><td>Sì</td><td>Sì</td></tr>
|
||||
<tr><td>YouTube</td><td>No</td><td>Match TV Light</td><td>Canale società</td></tr>
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<h2>2. Chi può usare il servizio</h2>
|
||||
<ul>
|
||||
<li>Il referente che registra la squadra dichiara di avere almeno <strong>18 anni</strong> e poteri per vincolare la società sportiva.</li>
|
||||
<li>Lo staff invitato (trasmissione, regia) usa credenziali personali e si impegna a custodirle.</li>
|
||||
<li>I responsabili trasmissione invitati usano credenziali personali e si impegnano a custodirle.</li>
|
||||
<li>Gli spettatori che aprono il link della diretta di solito non necessitano di account.</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
<div class="card">
|
||||
<%= form_with url: public_password_reset_path, method: :patch, local: true do %>
|
||||
<%= hidden_field_tag :token, @token %>
|
||||
<%= label_tag :password, "Nuova password (min. 8 caratteri)" %>
|
||||
<%= password_field_tag :password, required: true, minlength: 8, autocomplete: "new-password" %>
|
||||
<%= label_tag :password_confirmation, "Conferma password" %>
|
||||
<%= password_field_tag :password_confirmation, required: true, autocomplete: "new-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password,
|
||||
label: "Nuova password (min. 8 caratteri)",
|
||||
input_type: "text",
|
||||
required: true,
|
||||
minlength: 8,
|
||||
autocomplete: "new-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password_confirmation,
|
||||
label: "Conferma password",
|
||||
input_type: "text",
|
||||
required: true,
|
||||
autocomplete: "new-password" %>
|
||||
<%= submit_tag "Salva password", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<p class="auth-footer"><%= link_to "Torna al login", public_login_path %></p>
|
||||
|
||||
97
backend/app/views/public/regia/show.html.erb
Normal file
97
backend/app/views/public/regia/show.html.erb
Normal file
@@ -0,0 +1,97 @@
|
||||
<% content_for :title, "Regia — #{@team.name} vs #{@match.opponent_name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<% content_for :head do %>
|
||||
<% unless @stream_closed %>
|
||||
<script src="https://cdn.jsdelivr.net/npm/hls.js@1.5.7"></script>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<div class="regia-page" id="regia-app"
|
||||
data-token="<%= j params[:token] %>"
|
||||
data-status-url="<%= j public_regia_status_path(params[:token]) %>"
|
||||
data-score-url="<%= j public_regia_score_path(params[:token]) %>"
|
||||
data-pause-url="<%= j public_regia_pause_path(params[:token]) %>"
|
||||
data-stop-url="<%= j public_regia_stop_path(params[:token]) %>"
|
||||
data-cable-url="<%= j "/cable?regia_token=#{params[:token]}" %>"
|
||||
data-hls-url="<%= j @session.hls_playback_url %>"
|
||||
data-home-name="<%= j @team.name %>"
|
||||
data-away-name="<%= j @match.opponent_name %>"
|
||||
data-share-url="<%= j @share_url %>"
|
||||
data-share-title="<%= j @share_title %>"
|
||||
data-points-target="<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %>"
|
||||
data-stream-closed="<%= @stream_closed %>">
|
||||
|
||||
<header class="regia-header">
|
||||
<h1><%= @team.name %> vs <%= @match.opponent_name %></h1>
|
||||
<p>Regia · Set <%= @score.current_set %></p>
|
||||
<span id="regia-badge" class="regia-badge <%= @on_air ? 'regia-badge--live' : (@stream_closed ? 'regia-badge--ended' : 'regia-badge--wait') %>">
|
||||
<%= @stream_closed ? "Terminata" : (@on_air ? "In onda" : "In attesa") %>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="regia-share">
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-share" style="margin:0">
|
||||
Condividi link regia
|
||||
</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-copy" style="margin:0">
|
||||
Copia link
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="regia-preview">
|
||||
<% unless @stream_closed %>
|
||||
<video id="regia-preview" playsinline muted autoplay></video>
|
||||
<% end %>
|
||||
<div id="regia-preview-placeholder" class="regia-preview__placeholder"<%= ' hidden' unless @stream_closed %>>
|
||||
<%= @stream_closed ? "Diretta terminata" : "Anteprima in attesa del segnale…" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="regia-scoreboard">
|
||||
<p class="regia-sets" id="sets-line"><%= live_score_sets_label(@score) || "—" %></p>
|
||||
<p class="regia-score-line" id="score-line"><%= live_score_points_label(@match, @score) %></p>
|
||||
<p class="regia-partials" id="partials-line"<%= " hidden" unless live_score_partials_label(@score).present? %>>
|
||||
Parziali: <%= live_score_partials_label(@score) %>
|
||||
</p>
|
||||
|
||||
<div class="regia-team-row">
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @team.name %></div>
|
||||
<div class="regia-points" id="home-points"><%= @score.home_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="home_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="home_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
<div style="text-align:center;color:var(--regia-muted);font-size:0.72rem;padding-top:24px">
|
||||
<%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt
|
||||
</div>
|
||||
<div>
|
||||
<div class="regia-team-name"><%= @match.opponent_name %></div>
|
||||
<div class="regia-points" id="away-points"><%= @score.away_points %></div>
|
||||
<button type="button" class="regia-btn regia-btn--point" data-action="away_point" style="width:100%;margin-top:8px">+1</button>
|
||||
<button type="button" class="regia-btn regia-btn--minus" data-action="away_undo" style="width:100%;margin-top:6px">−</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="regia-btn regia-btn--yellow" data-action="close_set" style="margin-top:12px">Chiudi set</button>
|
||||
</section>
|
||||
|
||||
<% unless @stream_closed %>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="btn-pause">Interrompi (riprendibile)</button>
|
||||
<button type="button" class="regia-btn regia-btn--danger" id="btn-stop">Chiudi diretta</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div id="regia-toast" class="regia-toast" role="status"></div>
|
||||
|
||||
<div id="regia-modal" class="regia-modal" aria-hidden="true">
|
||||
<div class="regia-modal__card">
|
||||
<h2 id="modal-title">Set vinto</h2>
|
||||
<p id="modal-body"></p>
|
||||
<div class="regia-modal__actions">
|
||||
<button type="button" class="regia-btn regia-btn--yellow" id="modal-confirm">Chiudi set</button>
|
||||
<button type="button" class="regia-btn regia-btn--outline" id="modal-cancel">Annulla</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/regia.js?v=1"></script>
|
||||
@@ -9,12 +9,29 @@
|
||||
<%= form_with model: @user, url: public_signup_path do |f| %>
|
||||
<%= f.label :name, "Nome" %>
|
||||
<%= f.text_field :name, required: true %>
|
||||
<%= f.label :email, "Email" %>
|
||||
<%= f.email_field :email, required: true %>
|
||||
<%= f.label :password, "Password" %>
|
||||
<%= f.password_field :password, required: true, minlength: 8 %>
|
||||
<%= f.label :password_confirmation, "Conferma password" %>
|
||||
<%= f.password_field :password_confirmation, required: true %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: "user[email]",
|
||||
id: "user_email",
|
||||
label: "Email",
|
||||
value: @user.email,
|
||||
input_type: "email",
|
||||
required: true,
|
||||
autocomplete: "email" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: "user[password]",
|
||||
id: "user_password",
|
||||
label: "Password",
|
||||
input_type: "text",
|
||||
required: true,
|
||||
minlength: 8,
|
||||
autocomplete: "new-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: "user[password_confirmation]",
|
||||
id: "user_password_confirmation",
|
||||
label: "Conferma password",
|
||||
input_type: "text",
|
||||
required: true,
|
||||
autocomplete: "new-password" %>
|
||||
<label class="legal-accept">
|
||||
<%= check_box_tag :accept_terms, "1", false, required: true %>
|
||||
<span class="legal-accept-text">
|
||||
|
||||
@@ -6,10 +6,19 @@
|
||||
<h1>Accedi</h1>
|
||||
<div class="card">
|
||||
<%= form_with url: public_login_path do |f| %>
|
||||
<%= label_tag :email, "Email" %>
|
||||
<%= email_field_tag :email, params[:email], required: true %>
|
||||
<%= label_tag :password, "Password" %>
|
||||
<%= password_field_tag :password, required: true, autocomplete: "current-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :email,
|
||||
label: "Email",
|
||||
value: params[:email],
|
||||
input_type: "email",
|
||||
required: true,
|
||||
autocomplete: "username" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password,
|
||||
label: "Password",
|
||||
input_type: "text",
|
||||
required: true,
|
||||
autocomplete: "current-password" %>
|
||||
<p class="auth-forgot"><%= link_to "Password dimenticata?", public_password_forgot_path %></p>
|
||||
<%= submit_tag "Entra", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<% content_for :title, "Modifica — #{@member.full_name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:520px">
|
||||
<p class="muted"><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
||||
<h1><%= @member.full_name %></h1>
|
||||
<%= render "shared/roster_member_form", member: @member, team: @team %>
|
||||
</div>
|
||||
100
backend/app/views/public/teams/_streaming_staff.html.erb
Normal file
100
backend/app/views/public/teams/_streaming_staff.html.erb
Normal file
@@ -0,0 +1,100 @@
|
||||
<% team = local_assigns[:team] %>
|
||||
<% can_manage = local_assigns[:can_manage] %>
|
||||
<% owner_membership = local_assigns[:owner_membership] %>
|
||||
<% staff_memberships = local_assigns[:staff_memberships] %>
|
||||
<% pending_invitations = local_assigns[:pending_invitations] %>
|
||||
<% recordings = local_assigns[:recordings] %>
|
||||
<% entitlements = local_assigns[:entitlements] %>
|
||||
|
||||
<section class="team-streaming-staff">
|
||||
<% if can_manage %>
|
||||
<% if owner_membership&.staff_kind.blank? %>
|
||||
<div class="card team-streaming-staff__self">
|
||||
<h2>Il tuo account (<%= current_user.email %>)</h2>
|
||||
<p class="muted">
|
||||
Puoi gestire la trasmissione dall’app. Per il punteggio da un altro telefono, condividi il
|
||||
<strong>link regia</strong> durante la diretta (non serve un secondo account).
|
||||
</p>
|
||||
<div class="team-details-actions">
|
||||
<%= button_to "Sono io — responsabile trasmissione", public_team_assign_self_staff_path(team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
<% end %>
|
||||
|
||||
<h2>Responsabili trasmissione (app)</h2>
|
||||
<div class="card">
|
||||
<% if staff_memberships.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Nome</th><th>Email</th><th>Ruolo</th><% if can_manage %><th></th><% end %></tr></thead>
|
||||
<tbody>
|
||||
<% staff_memberships.each do |ut| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= ut.user.name %>
|
||||
<% if ut.user_id == current_user.id %><span class="muted"> (tu)</span><% end %>
|
||||
</td>
|
||||
<td><%= ut.user.email %></td>
|
||||
<td>Trasmissione</td>
|
||||
<% if can_manage %>
|
||||
<td>
|
||||
<% if ut.user_id == current_user.id && team.club.owned_by?(current_user) %>
|
||||
<%= button_to "Rimuovi ruolo staff", public_team_clear_self_staff_path(team), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Rimuovere il ruolo staff dal tuo account?" } } %>
|
||||
<% elsif ut.role == "member" %>
|
||||
<%= button_to "Revoca", public_team_remove_member_path(team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Revocare l'accesso a #{ut.user.name}?" } } %>
|
||||
<% end %>
|
||||
</td>
|
||||
<% end %>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="muted">
|
||||
Nessun responsabile trasmissione.
|
||||
<% if can_manage %><%= link_to "Invita qualcuno", public_team_invite_path(team) %>.<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if can_manage %>
|
||||
<h2>Inviti in attesa</h2>
|
||||
<div class="card">
|
||||
<% if pending_invitations.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Email</th><th>Scade</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% pending_invitations.each do |inv| %>
|
||||
<tr>
|
||||
<td><%= inv.email %></td>
|
||||
<td><%= l inv.expires_at, format: :short %></td>
|
||||
<td>
|
||||
<%= button_to "Annulla", public_team_destroy_invitation_path(team, inv), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p class="muted">Nessuna richiesta in sospeso.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<% if entitlements.can_access_recordings? && recordings.any? %>
|
||||
<h2>Archivio gare</h2>
|
||||
<div class="card">
|
||||
<table class="data">
|
||||
<thead><tr><th>Partita</th><th>Replay</th></tr></thead>
|
||||
<tbody>
|
||||
<% recordings.each do |rec| %>
|
||||
<tr>
|
||||
<td><%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %></td>
|
||||
<td><%= link_to "Guarda", rec.replay_url %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
@@ -20,7 +20,6 @@
|
||||
<% end %>
|
||||
<ul>
|
||||
<li>Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno</li>
|
||||
<li>Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno</li>
|
||||
<li>Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea</li>
|
||||
<li>Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %></li>
|
||||
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||
@@ -46,5 +45,5 @@
|
||||
<p style="margin-top:20px"><%= button_to "Gestisci su Stripe", public_team_portal_path(@team), class: "btn btn-secondary" %></p>
|
||||
<% end %>
|
||||
|
||||
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>
|
||||
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
||||
</div>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<% content_for :title, "#{@team.name} — Dashboard" %>
|
||||
<% content_for :meta_description, "Dashboard squadra #{@team.name} su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<p style="color:#888;margin:0 0 8px"><%= link_to @club.name, public_club_path(@club) %> · <%= link_to "Modifica squadra", public_edit_team_path(@team) %></p>
|
||||
<h1 style="display:flex;align-items:center;gap:12px">
|
||||
<% if @team.effective_logo_url.present? %>
|
||||
<%= image_tag @team.effective_logo_url, alt: "", width: 40, height: 40, style: "border-radius:8px" %>
|
||||
<% end %>
|
||||
<%= @team.name %>
|
||||
</h1>
|
||||
<p>
|
||||
Piano: <strong><%= @entitlements.plan.name %></strong> (società)
|
||||
· Staff trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||
· Staff regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
||||
· Partite attive: <strong><%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %></strong>
|
||||
</p>
|
||||
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin:20px 0">
|
||||
<%= link_to "Abbonamento società", public_club_billing_path(@club), class: "btn btn-primary" %>
|
||||
<%= link_to "Aggiungi staff", public_team_invite_path(@team), class: "btn btn-secondary" %>
|
||||
<%= link_to "Dirette live", public_live_index_path, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<% owner_staff = @owner_membership&.staff_kind %>
|
||||
<% can_self_transmission = owner_staff != "transmission" && owner_staff != "regia" %>
|
||||
<% can_self_regia = owner_staff != "regia" && owner_staff != "transmission" %>
|
||||
<% if can_self_transmission || can_self_regia %>
|
||||
<div class="card" style="margin-bottom:20px">
|
||||
<h2 style="margin-top:0;font-size:1.1rem">Il tuo account (<%= current_user.email %>)</h2>
|
||||
<p style="color:#aaa;font-size:0.92rem;margin-bottom:12px">
|
||||
Puoi usare le stesse credenziali nell’app mobile. Trasmissione e regia devono essere <strong>email diverse</strong> — non puoi coprire entrambi i ruoli con questo account.
|
||||
</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<% if can_self_transmission %>
|
||||
<%= button_to "Sono io — staff trasmissione", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<% if can_self_regia %>
|
||||
<%= button_to "Sono io — staff regia", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "regia" }, class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<h2>Staff attivo</h2>
|
||||
<div class="card">
|
||||
<% if @staff_memberships.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Nome</th><th>Email</th><th>Ruolo</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @staff_memberships.each do |ut| %>
|
||||
<tr>
|
||||
<td>
|
||||
<%= ut.user.name %>
|
||||
<% if ut.role == "owner" %><span style="color:#888;font-size:0.85rem"> (tu)</span><% end %>
|
||||
</td>
|
||||
<td><%= ut.user.email %></td>
|
||||
<td><%= ut.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
|
||||
<td>
|
||||
<% if ut.role == "owner" %>
|
||||
<%= button_to "Rimuovi ruolo staff", public_team_clear_self_staff_path(@team), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Rimuovere il ruolo staff dal tuo account?" } } %>
|
||||
<% else %>
|
||||
<%= button_to "Revoca", public_team_remove_member_path(@team, ut.user), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem", form: { data: { turbo_confirm: "Revocare l'accesso a #{ut.user.name}?" } } %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessun membro staff. Usa i pulsanti sopra per il tuo account o <%= link_to "invita qualcuno", public_team_invite_path(@team) %>.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<h2>Staff in attesa</h2>
|
||||
<div class="card">
|
||||
<% if @pending_invitations.any? %>
|
||||
<table class="data">
|
||||
<thead><tr><th>Email</th><th>Ruolo</th><th>Scade</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
<% @pending_invitations.each do |inv| %>
|
||||
<tr>
|
||||
<td><%= inv.email %></td>
|
||||
<td><%= inv.staff_kind == "regia" ? "Regia" : "Trasmissione" %></td>
|
||||
<td><%= l inv.expires_at, format: :short %></td>
|
||||
<td>
|
||||
<%= button_to "Annulla", public_team_destroy_invitation_path(@team, inv), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem" %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessuna richiesta in sospeso.</p>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if @entitlements.can_access_recordings? && @recordings.any? %>
|
||||
<h2>Archivio gare</h2>
|
||||
<div class="card">
|
||||
<table class="data">
|
||||
<thead><tr><th>Partita</th><th>Replay</th></tr></thead>
|
||||
<tbody>
|
||||
<% @recordings.each do |rec| %>
|
||||
<tr>
|
||||
<td><%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %></td>
|
||||
<td><%= link_to "Guarda", rec.replay_url %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<p style="color:#888;margin-top:24px">Usa l'app mobile con le stesse credenziali per avviare partite e dirette.</p>
|
||||
</div>
|
||||
101
backend/app/views/public/teams/details.html.erb
Normal file
101
backend/app/views/public/teams/details.html.erb
Normal file
@@ -0,0 +1,101 @@
|
||||
<% content_for :title, "Dettagli — #{@team.name}" %>
|
||||
<% content_for :meta_description, "Dettagli squadra #{@team.name} su Match Live TV." %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
<% total_people = @roster_by_category.values.sum(&:size) %>
|
||||
|
||||
<div class="wrap team-roster-page">
|
||||
<nav class="team-dashboard-nav" aria-label="Percorso squadra">
|
||||
<% if @can_manage %>
|
||||
<%= link_to "← #{@club.name}", public_club_path(@club), class: "team-dashboard-nav__club" %>
|
||||
<span class="team-dashboard-nav__sep">·</span>
|
||||
<%= link_to "Modifica squadra", public_edit_team_path(@team) %>
|
||||
<% else %>
|
||||
<span class="team-dashboard-nav__club"><%= @club.name %></span>
|
||||
<% end %>
|
||||
</nav>
|
||||
|
||||
<header class="roster-hero card">
|
||||
<div class="roster-hero__intro">
|
||||
<p class="roster-hero__club"><%= @club.name %></p>
|
||||
<h1 class="roster-hero__title"><%= @team.name %></h1>
|
||||
<p class="team-details-meta">
|
||||
Piano: <strong><%= @entitlements.plan.name %></strong> (società)
|
||||
· Responsabili trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||
· Partite attive: <strong><%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %></strong>
|
||||
</p>
|
||||
<% if @team.description.present? %>
|
||||
<div class="roster-hero__desc"><%= simple_format @team.description %></div>
|
||||
<% elsif @can_manage %>
|
||||
<p class="roster-hero__desc roster-hero__desc--muted">
|
||||
Nessuna descrizione.
|
||||
<%= link_to "Aggiungila dalla modifica squadra", public_edit_team_path(@team) %>.
|
||||
</p>
|
||||
<% end %>
|
||||
<div class="roster-stats">
|
||||
<span class="roster-stat"><strong><%= total_people %></strong> in organico</span>
|
||||
<% TeamRosterMember::DISPLAY_ORDER.each do |cat| %>
|
||||
<% count = @roster_by_category[cat].size %>
|
||||
<% next if count.zero? %>
|
||||
<span class="roster-stat"><%= count %> <%= TeamRosterMember::CATEGORY_LABELS[cat].downcase %></span>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="roster-hero__media">
|
||||
<% if @team.team_photo_url.present? %>
|
||||
<%= image_tag @team.team_photo_url, alt: @team.name, class: "roster-hero__photo" %>
|
||||
<% elsif @can_manage %>
|
||||
<div class="roster-hero__photo roster-hero__photo--empty">
|
||||
<span>Foto squadra non ancora caricata</span>
|
||||
<%= link_to "Aggiungi dalla modifica squadra", public_edit_team_path(@team), class: "btn btn-secondary", style: "margin-top:8px;padding:8px 14px;font-size:0.85rem" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="team-details-actions">
|
||||
<% if @can_manage %>
|
||||
<%= link_to "← Società", public_club_path(@club), class: "btn btn-secondary" %>
|
||||
<%= link_to "Responsabili trasmissione", public_team_invite_path(@team), class: "btn btn-secondary" %>
|
||||
<% end %>
|
||||
<% if current_user.can_stream_for?(@team) %>
|
||||
<%= link_to "Programma partite", public_team_matches_path(@team), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<%= link_to "Dirette live", public_live_index_path(club_id: @club.id), class: "btn btn-secondary" %>
|
||||
</div>
|
||||
|
||||
<div class="roster-layout<%= " roster-layout--readonly" unless @can_manage %>">
|
||||
<section class="roster-main" aria-label="Organico">
|
||||
<% TeamRosterMember::DISPLAY_ORDER.each do |category| %>
|
||||
<% members = @roster_by_category[category] %>
|
||||
<section class="roster-section card">
|
||||
<header class="roster-section__head">
|
||||
<h2 class="roster-section__title"><%= TeamRosterMember::CATEGORY_LABELS[category] %></h2>
|
||||
<span class="roster-section__count"><%= members.size %></span>
|
||||
</header>
|
||||
<% if members.any? %>
|
||||
<div class="roster-list">
|
||||
<% members.each do |person| %>
|
||||
<%= render "shared/roster_person_card", person: person, team: @team, editable: @can_manage %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="roster-section__empty">Nessuna persona in questa categoria.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<% if @can_manage %>
|
||||
<aside class="roster-sidebar" aria-label="Aggiungi persona">
|
||||
<%= render "shared/roster_member_form", member: @roster_member, team: @team %>
|
||||
</aside>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%= render "public/teams/streaming_staff", team: @team, club: @club, can_manage: @can_manage,
|
||||
entitlements: @entitlements, owner_membership: @owner_membership,
|
||||
staff_memberships: @staff_memberships, pending_invitations: @pending_invitations,
|
||||
recordings: @recordings %>
|
||||
|
||||
<p class="team-details-footer">Usa l'app mobile con le stesse credenziali per avviare partite e dirette.</p>
|
||||
</div>
|
||||
@@ -10,6 +10,20 @@
|
||||
<%= text_field_tag "team[name]", @team.name, required: true %>
|
||||
<%= label_tag "team[sport]", "Sport" %>
|
||||
<%= select_tag "team[sport]", options_for_select([["Pallavolo", "volleyball"], ["Calcio", "football"], ["Basket", "basketball"]], @team.sport) %>
|
||||
|
||||
<%= label_tag "team[description]", "Descrizione squadra" %>
|
||||
<%= text_area_tag "team[description]", @team.description, rows: 5, placeholder: "Presentazione della squadra, palmarès, obiettivi della stagione…" %>
|
||||
|
||||
<div class="branding-logo-block">
|
||||
<%= label_tag "team[photo_file]", "Foto di squadra (gruppo o stemma)" %>
|
||||
<% if @team.team_photo_url.present? %>
|
||||
<div class="branding-logo-preview">
|
||||
<%= image_tag @team.team_photo_url, alt: "Foto squadra", class: "team-profile-photo-preview", width: 200 %>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= file_field_tag "team[photo_file]", accept: "image/png,image/jpeg,image/webp", class: "branding-file-input" %>
|
||||
</div>
|
||||
|
||||
<%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: "Branding squadra (override)" %>
|
||||
<p class="branding-hint">
|
||||
Colori società:
|
||||
@@ -19,5 +33,5 @@
|
||||
<%= submit_tag "Salva squadra", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<p><%= link_to "← Dashboard squadra", public_team_dashboard_path(@team) %></p>
|
||||
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
||||
</div>
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
<% content_for :title, "Aggiungi staff — #{@team.name}" %>
|
||||
<% content_for :title, "Staff trasmissione — #{@team.name}" %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px">
|
||||
<h1>Aggiungi staff</h1>
|
||||
<h1>Responsabili trasmissione</h1>
|
||||
<p>
|
||||
Trasmissione: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||
· Regia: <strong><%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %></strong>
|
||||
Assegnati: <strong><%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %></strong>
|
||||
(per squadra, rinnovo annuale)
|
||||
</p>
|
||||
<p class="muted" style="max-width:640px">
|
||||
Il responsabile trasmissione avvia la diretta dall’app (camera sul cavalletto).
|
||||
Per il punteggio da un secondo telefono non serve un account: condivide il <strong>link regia</strong> dalla schermata di trasmissione
|
||||
(WhatsApp, email, SMS, ecc.).
|
||||
</p>
|
||||
|
||||
<% owner_staff = current_user.user_teams.find_by(team: @team, role: "owner")&.staff_kind %>
|
||||
<% unless owner_staff.present? %>
|
||||
<% owner_membership = current_user.user_teams.find_by(team: @team) %>
|
||||
<% if owner_membership&.staff_kind.blank? %>
|
||||
<div class="card" style="max-width:520px;margin-bottom:16px">
|
||||
<p style="margin:0 0 12px;color:#aaa;font-size:0.92rem">
|
||||
<strong><%= current_user.email %></strong> — aggiungi il tuo account senza invito (non potrai essere sia trasmissione sia regia).
|
||||
<strong><%= current_user.email %></strong> — puoi gestire tu camera e punteggio, oppure invitare altri per la trasmissione.
|
||||
</p>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap">
|
||||
<%= button_to "Sono io — trasmissione", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
||||
<%= button_to "Sono io — regia", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "regia" }, class: "btn btn-secondary" %>
|
||||
</div>
|
||||
<%= button_to "Sono io — responsabile trasmissione", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="card" style="max-width:520px">
|
||||
<h2 style="margin-top:0;font-size:1.05rem">Invita un’altra email</h2>
|
||||
<%= form_with url: public_team_invite_path(@team), method: :post do %>
|
||||
<%= label_tag :staff_kind, "Ruolo" %>
|
||||
<%= select_tag :staff_kind, options_for_select([
|
||||
["Trasmissione (camera / streaming)", "transmission"],
|
||||
["Regia (punteggio)", "regia"]
|
||||
], params[:staff_kind] || "transmission") %>
|
||||
<%= label_tag :email, "Email del membro staff" %>
|
||||
<%= hidden_field_tag :staff_kind, "transmission" %>
|
||||
<%= label_tag :email, "Email del responsabile trasmissione" %>
|
||||
<%= email_field_tag :email, params[:email], required: true %>
|
||||
<%= submit_tag "Genera link di accesso", class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<% if defined?(@invite_url) && @invite_url.present? %>
|
||||
<p style="margin-top:16px">Condividi (valido 7 giorni):</p>
|
||||
<code style="word-break:break-all;display:block;background:#0a0a0e;padding:12px;border-radius:8px"><%= @invite_url %></code>
|
||||
<p style="color:#888;font-size:0.88rem;margin-top:8px">Il membro dello staff si registra con la stessa email e accetta l'accesso. L'email non può essere già usata per l'altro ruolo (trasmissione vs regia).</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<p><%= link_to "← Dashboard", public_team_dashboard_path(@team) %></p>
|
||||
<p><%= link_to "← Dettagli squadra", public_team_details_path(@team) %></p>
|
||||
</div>
|
||||
|
||||
94
backend/app/views/shared/_billing_documents.html.erb
Normal file
94
backend/app/views/shared/_billing_documents.html.erb
Normal file
@@ -0,0 +1,94 @@
|
||||
<%# locals: (club:, payments:, invoices:) %>
|
||||
<section class="billing-documents" style="margin-top:40px">
|
||||
<h2>Pagamenti e fatture</h2>
|
||||
<p style="color:#888;font-size:0.9rem">
|
||||
Qui trovi lo storico dei pagamenti e le fatture emesse alla società.
|
||||
I pagamenti premium sono elaborati in modo sicuro tramite Stripe.
|
||||
</p>
|
||||
|
||||
<% unless club.billing_profile_complete? %>
|
||||
<div class="flash alert" style="margin:16px 0">
|
||||
<strong>Dati di fatturazione incompleti.</strong>
|
||||
Compila ragione sociale, indirizzo, P.IVA/CF e SDI o PEC per ricevere le fatture.
|
||||
<%= link_to "Completa ora", public_club_billing_profile_path(club), class: "btn btn-secondary", style: "margin-top:10px;display:inline-block" %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p style="color:#888;font-size:0.88rem">
|
||||
Intestatario: <%= club.billing_profile_summary %>
|
||||
· <%= link_to "Modifica dati", public_club_billing_profile_path(club) %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<% if MatchLiveTv.stripe_enabled? && club.subscription&.stripe_customer_id.present? %>
|
||||
<%= button_to "Sincronizza pagamenti da Stripe",
|
||||
public_club_billing_sync_payments_path(club),
|
||||
class: "btn btn-secondary",
|
||||
style: "margin:12px 0" %>
|
||||
<% end %>
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti effettuati</h3>
|
||||
<% if payments.any? %>
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th>
|
||||
<th>Descrizione</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% payments.each do |payment| %>
|
||||
<tr>
|
||||
<td><%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %></td>
|
||||
<td><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %></td>
|
||||
<td><%= payment.formatted_amount %></td>
|
||||
<td><%= payment.status %></td>
|
||||
<td>
|
||||
<% if payment.receipt_url.present? %>
|
||||
<%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui (anche tramite «Sincronizza pagamenti»).</p>
|
||||
<% end %>
|
||||
|
||||
<h3 style="margin-top:28px;font-size:1.1rem">Fatture</h3>
|
||||
<% if invoices.any? %>
|
||||
<table class="data billing-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Numero</th>
|
||||
<th>Data</th>
|
||||
<th>Importo</th>
|
||||
<th>Stato</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% invoices.each do |invoice| %>
|
||||
<tr>
|
||||
<td><%= invoice.number %></td>
|
||||
<td><%= l(invoice.issued_on) %></td>
|
||||
<td><%= invoice.formatted_amount %></td>
|
||||
<td><%= invoice.status %></td>
|
||||
<td>
|
||||
<% if invoice.downloadable? %>
|
||||
<%= link_to "Scarica PDF", public_club_billing_invoice_path(club, invoice) %>
|
||||
<% else %>
|
||||
<span style="color:#888">PDF in preparazione</span>
|
||||
<% end %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p style="color:#888">Nessuna fattura disponibile. Compariranno qui dopo l’emissione.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
65
backend/app/views/shared/_billing_profile_fields.html.erb
Normal file
65
backend/app/views/shared/_billing_profile_fields.html.erb
Normal file
@@ -0,0 +1,65 @@
|
||||
<%# locals: (record:, show_legend: true) %>
|
||||
<% if show_legend %>
|
||||
<h2 class="form-section-title" style="margin-top:24px">Dati di fatturazione</h2>
|
||||
<p style="color:#888;font-size:0.88rem;margin:-8px 0 16px">
|
||||
Obbligatori per ricevere le fatture elettroniche (P.IVA/CF, indirizzo, SDI o PEC).
|
||||
I pagamenti restano gestiti in modo sicuro da Stripe.
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= label_tag "club[billing_entity_type]", "Tipo intestatario" %>
|
||||
<%= select_tag "club[billing_entity_type]",
|
||||
options_for_select(Club::BILLING_ENTITY_TYPES.map { |k, v| [v, k] }, record.billing_entity_type),
|
||||
include_blank: false %>
|
||||
|
||||
<%= label_tag "club[billing_legal_name]", "Ragione sociale / nome e cognome" %>
|
||||
<%= text_field_tag "club[billing_legal_name]", record.billing_legal_name, placeholder: "es. ASD Tigers Volley" %>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<%= label_tag "club[billing_vat_number]", "Partita IVA" %>
|
||||
<%= text_field_tag "club[billing_vat_number]", record.billing_vat_number, placeholder: "12345678901" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_fiscal_code]", "Codice Fiscale" %>
|
||||
<%= text_field_tag "club[billing_fiscal_code]", record.billing_fiscal_code, placeholder: "RSSMRA80A01H501U" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= label_tag "club[billing_email]", "Email fatturazione" %>
|
||||
<%= email_field_tag "club[billing_email]", record.billing_email, placeholder: "amministrazione@societa.it" %>
|
||||
|
||||
<%= label_tag "club[billing_phone]", "Telefono (opzionale)" %>
|
||||
<%= text_field_tag "club[billing_phone]", record.billing_phone %>
|
||||
|
||||
<%= label_tag "club[billing_address_line]", "Indirizzo" %>
|
||||
<%= text_field_tag "club[billing_address_line]", record.billing_address_line, placeholder: "Via Roma 1" %>
|
||||
|
||||
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<%= label_tag "club[billing_city]", "Città" %>
|
||||
<%= text_field_tag "club[billing_city]", record.billing_city %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_province]", "Prov." %>
|
||||
<%= text_field_tag "club[billing_province]", record.billing_province, maxlength: 2, placeholder: "MI" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_postal_code]", "CAP" %>
|
||||
<%= text_field_tag "club[billing_postal_code]", record.billing_postal_code %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= label_tag "club[billing_country]", "Paese (ISO)" %>
|
||||
<%= text_field_tag "club[billing_country]", record.billing_country.presence || "IT", maxlength: 2 %>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||
<div>
|
||||
<%= label_tag "club[billing_recipient_code]", "Codice destinatario SDI (7 caratteri)" %>
|
||||
<%= text_field_tag "club[billing_recipient_code]", record.billing_recipient_code, maxlength: 7, placeholder: "XXXXXXX" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= label_tag "club[billing_pec]", "PEC (alternativa a SDI)" %>
|
||||
<%= email_field_tag "club[billing_pec]", record.billing_pec, placeholder: "pec@societa.it" %>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,5 +1,9 @@
|
||||
<% record = local_assigns[:record] %>
|
||||
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
|
||||
<% param_key = record.model_name.param_key %>
|
||||
<% primary = record.primary_color.presence || record.effective_primary_color %>
|
||||
<% secondary = record.secondary_color.presence || record.effective_secondary_color %>
|
||||
<% logo_src = record.effective_logo_url.presence %>
|
||||
|
||||
<fieldset class="branding-fields">
|
||||
<legend><%= local_assigns[:legend] || "Identità visiva" %></legend>
|
||||
@@ -8,26 +12,59 @@
|
||||
<p class="branding-hint">Lascia vuoto per usare logo e colori della società.</p>
|
||||
<% end %>
|
||||
|
||||
<%= label_tag "branding[logo_file]", "Logo (immagine)" %>
|
||||
<%= file_field_tag "branding[logo_file]", accept: "image/png,image/jpeg,image/webp" %>
|
||||
<% if record.effective_logo_url.present? %>
|
||||
<p class="branding-preview">
|
||||
<%= image_tag record.effective_logo_url, alt: "", width: 64, height: 64, style: "border-radius:8px" %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
<%= label_tag "#{record.model_name.param_key}[logo_url]", "Logo (URL alternativo)" %>
|
||||
<%= text_field_tag "#{record.model_name.param_key}[logo_url]", record.logo_url, placeholder: "https://…" %>
|
||||
|
||||
<%= label_tag "#{record.model_name.param_key}[primary_color]", "Colore principale" %>
|
||||
<div class="color-field">
|
||||
<%= color_field_tag "#{record.model_name.param_key}[primary_color]", record.primary_color.presence || record.effective_primary_color %>
|
||||
<span class="color-swatch" style="background:<%= record.effective_primary_color %>"></span>
|
||||
<div class="branding-logo-block">
|
||||
<%= label_tag "branding[logo_file]", "Logo (immagine)" %>
|
||||
<div class="branding-logo-preview" data-branding-logo-preview>
|
||||
<%= image_tag logo_src.presence || "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
|
||||
alt: "Anteprima logo",
|
||||
class: "branding-logo-preview__img",
|
||||
data: { branding_logo_img: true },
|
||||
width: 96,
|
||||
height: 96,
|
||||
hidden: logo_src.blank? %>
|
||||
<span class="branding-logo-preview__placeholder" data-branding-logo-placeholder <%= "hidden" if logo_src.present? %>>
|
||||
Nessun logo — scegli un file per l’anteprima
|
||||
</span>
|
||||
</div>
|
||||
<%= file_field_tag "branding[logo_file]", accept: "image/png,image/jpeg,image/webp", data: { branding_logo_file: true }, class: "branding-file-input" %>
|
||||
</div>
|
||||
|
||||
<%= label_tag "#{record.model_name.param_key}[secondary_color]", "Colore secondario" %>
|
||||
<div class="color-field">
|
||||
<%= color_field_tag "#{record.model_name.param_key}[secondary_color]", record.secondary_color.presence || record.effective_secondary_color %>
|
||||
<span class="color-swatch" style="background:<%= record.effective_secondary_color %>"></span>
|
||||
<%= label_tag "#{param_key}[logo_url]", "Logo (URL alternativo)" %>
|
||||
<%= text_field_tag "#{param_key}[logo_url]", record.logo_url, placeholder: "https://…", class: "branding-url-input" %>
|
||||
|
||||
<%= label_tag "#{param_key}[primary_color]", "Colore principale" %>
|
||||
<div class="branding-color-row" data-branding-color-row="primary">
|
||||
<%= color_field_tag "#{param_key}[primary_color]", primary, data: { color_picker: true }, class: "branding-color-picker", title: "Scegli colore" %>
|
||||
<%= text_field_tag nil, primary, data: { color_hex: true }, class: "branding-hex-input", placeholder: "#e53935", autocomplete: "off", spellcheck: false %>
|
||||
<span class="color-swatch branding-color-swatch" data-color-swatch aria-hidden="true"></span>
|
||||
<div class="branding-color-presets" aria-label="Colori rapidi">
|
||||
<% %w[#e53935 #1565c0 #2e7d32 #ff9800 #212121 #ffffff].each do |hex| %>
|
||||
<button type="button" class="branding-preset" data-color-preset="<%= hex %>" style="background:<%= hex %>" title="<%= hex %>"></button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= label_tag "#{param_key}[secondary_color]", "Colore secondario" %>
|
||||
<div class="branding-color-row" data-branding-color-row="secondary">
|
||||
<%= color_field_tag "#{param_key}[secondary_color]", secondary, data: { color_picker: true }, class: "branding-color-picker", title: "Scegli colore" %>
|
||||
<%= text_field_tag nil, secondary, data: { color_hex: true }, class: "branding-hex-input", placeholder: "#ffffff", autocomplete: "off", spellcheck: false %>
|
||||
<span class="color-swatch branding-color-swatch" data-color-swatch aria-hidden="true"></span>
|
||||
<div class="branding-color-presets" aria-label="Colori rapidi">
|
||||
<% %w[#ffffff #f5f5f5 #212121 #ffd54f #90caf9 #e53935].each do |hex| %>
|
||||
<button type="button" class="branding-preset" data-color-preset="<%= hex %>" style="background:<%= hex %>" title="<%= hex %>"></button>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="branding-live-preview" data-branding-live-preview>
|
||||
<span class="branding-live-preview__label">Anteprima intestazione</span>
|
||||
<div class="branding-live-preview__header" data-preview-header style="--preview-primary:<%= primary %>;--preview-secondary:<%= secondary %>">
|
||||
<span class="branding-live-preview__logo" data-preview-logo>
|
||||
<% if logo_src %>
|
||||
<%= image_tag logo_src, alt: "", width: 40, height: 40, style: "border-radius:8px;object-fit:contain" %>
|
||||
<% end %>
|
||||
</span>
|
||||
<span class="branding-live-preview__name"><%= record.try(:name).presence || "Nome società / squadra" %></span>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
19
backend/app/views/shared/_club_subscription_status.html.erb
Normal file
19
backend/app/views/shared/_club_subscription_status.html.erb
Normal file
@@ -0,0 +1,19 @@
|
||||
<%# locals: (club:, entitlements: nil, subscription: nil) %>
|
||||
<% current_plan = entitlements&.plan || subscription&.plan || Plan["free"] %>
|
||||
<p style="color:#aaa;margin-bottom:8px">
|
||||
Società: <strong><%= club.name %></strong>
|
||||
· Piano attuale: <strong><%= current_plan.name %></strong>
|
||||
(valido per tutte le squadre)
|
||||
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
|
||||
</p>
|
||||
<% if subscription&.stripe_subscription_id.present? %>
|
||||
<p style="color:#888;font-size:0.9rem;margin-top:0">
|
||||
Stato Stripe: <strong><%= subscription.status %></strong>
|
||||
<% if subscription.current_period_end.present? %>
|
||||
· Rinnovo: <strong><%= l(subscription.current_period_end, format: :long) %></strong>
|
||||
<% end %>
|
||||
<% if subscription.cancel_at_period_end? %>
|
||||
· <span style="color:#e53935">Disdetta alla scadenza</span>
|
||||
<% end %>
|
||||
</p>
|
||||
<% end %>
|
||||
22
backend/app/views/shared/_input_toggle.html.erb
Normal file
22
backend/app/views/shared/_input_toggle.html.erb
Normal file
@@ -0,0 +1,22 @@
|
||||
<%# locals: (name:, label:, value: nil, input_type: "text", required: false, autocomplete: nil, minlength: nil, id: nil) %>
|
||||
<% field_id = local_assigns.fetch(:id, nil).presence || name.to_s %>
|
||||
<% visible_type = local_assigns.fetch(:input_type, "text").to_s == "email" ? "email" : "text" %>
|
||||
<div class="input-toggle" data-input-toggle>
|
||||
<%= label_tag field_id, label %>
|
||||
<div class="input-toggle__field">
|
||||
<%= tag.input(
|
||||
type: "password",
|
||||
name: name,
|
||||
id: field_id,
|
||||
value: local_assigns.fetch(:value, nil),
|
||||
required: local_assigns.fetch(:required, false),
|
||||
autocomplete: local_assigns.fetch(:autocomplete, nil),
|
||||
minlength: local_assigns.fetch(:minlength, nil),
|
||||
class: "input-toggle__input",
|
||||
data: { visible_type: visible_type }
|
||||
) %>
|
||||
<button type="button" class="input-toggle__btn" aria-label="Mostra <%= label.downcase %>" data-label-hide="Nascondi <%= label.downcase %>">
|
||||
<i class="fa-regular fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -10,5 +10,8 @@
|
||||
<%= link_to "Privacy", public_privacy_path %> ·
|
||||
<%= link_to "Termini", public_termini_path %>
|
||||
</div>
|
||||
<div class="site-footer__stripe">
|
||||
<%= render "shared/stripe_secure_payment", compact: true %>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<% if current_user.primary_club %>
|
||||
<%= link_to "Società", public_club_path(current_user.primary_club), class: "nav-link-item" %>
|
||||
<% elsif current_user.manageable_teams.first %>
|
||||
<%= link_to "Dashboard", public_team_dashboard_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||
|
||||
55
backend/app/views/shared/_plan_cards.html.erb
Normal file
55
backend/app/views/shared/_plan_cards.html.erb
Normal file
@@ -0,0 +1,55 @@
|
||||
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false) %>
|
||||
<% club ||= @club %>
|
||||
<% entitlements ||= @entitlements %>
|
||||
<% subscription ||= @subscription %>
|
||||
<% current_slug = local_assigns.fetch(:current_plan_slug, nil).presence || entitlements&.plan&.slug || subscription&.plan&.slug || "free" %>
|
||||
<% billing_mode = club.present? %>
|
||||
|
||||
<div class="plans">
|
||||
<% @plans.each do |plan| %>
|
||||
<% action = billing_mode ? plan_billing_action(
|
||||
current_slug: current_slug,
|
||||
target_plan: plan,
|
||||
stripe_subscription_active: stripe_subscription_active?(subscription)
|
||||
) : nil %>
|
||||
<div class="plan-card <%= "featured" if plan.slug == current_slug || (!billing_mode && plan.slug == "premium_light") %>">
|
||||
<h3><%= plan.name %></h3>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
|
||||
<% else %>
|
||||
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
|
||||
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
|
||||
<% end %>
|
||||
<ul>
|
||||
<li>Responsabili trasmissione: <strong><%= plan.max_staff_transmission || "illimitato" %></strong> / anno per squadra</li>
|
||||
<li>Partite: <strong><%= plan.concurrent_streams_limit || "illimitate" %></strong> in contemporanea (tutta la società)</li>
|
||||
<li>Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %></li>
|
||||
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
|
||||
</ul>
|
||||
<% if billing_mode %>
|
||||
<% case action[:kind] %>
|
||||
<% when :current %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
||||
<% when :contact, :disabled %>
|
||||
<p style="color:#888;font-size:0.85rem"><%= action[:label] %></p>
|
||||
<% when :checkout, :change %>
|
||||
<%= link_to action[:label], public_club_checkout_path(club, plan: plan.slug), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
||||
<% else %>
|
||||
<%= link_to "Registrati", public_signup_path(plan: plan.slug), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<% if show_stripe_portal && billing_mode && subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription&.stripe_customer_id.present? %>
|
||||
<p style="margin-top:20px">
|
||||
<%= button_to "Gestisci fatturazione su Stripe", public_club_portal_path(club), class: "btn btn-secondary" %>
|
||||
<span style="color:#888;font-size:0.85rem;display:block;margin-top:8px">Cambia metodo di pagamento, scarica fatture o disdici l'abbonamento.</span>
|
||||
</p>
|
||||
<% end %>
|
||||
50
backend/app/views/shared/_roster_member_form.html.erb
Normal file
50
backend/app/views/shared/_roster_member_form.html.erb
Normal file
@@ -0,0 +1,50 @@
|
||||
<% member = local_assigns[:member] %>
|
||||
<% team = local_assigns[:team] %>
|
||||
<% form_url = member.persisted? ? public_team_roster_member_path(team, member) : public_team_roster_members_path(team) %>
|
||||
<% form_method = member.persisted? ? :patch : :post %>
|
||||
<% is_player = member.category == "player" %>
|
||||
<% player_role = member.role_label if member.role_label.in?(TeamRosterMember::VOLLEYBALL_PLAYER_ROLES) %>
|
||||
|
||||
<%= form_with url: form_url, method: form_method, multipart: true, class: "roster-member-form card roster-sidebar__form", data: { roster_form: true } do %>
|
||||
<h3 class="roster-sidebar__title"><%= member.persisted? ? "Modifica scheda" : "Aggiungi persona" %></h3>
|
||||
<p class="roster-sidebar__lead">Compila i campi e salva per aggiornare l’organico.</p>
|
||||
|
||||
<%= label_tag "team_roster_member[category]", "Gruppo" %>
|
||||
<%= select_tag "team_roster_member[category]", options_for_select(roster_category_options, member.category),
|
||||
data: { roster_category: true } %>
|
||||
|
||||
<%= label_tag "team_roster_member[full_name]", "Nome e cognome" %>
|
||||
<%= text_field_tag "team_roster_member[full_name]", member.full_name, required: true, placeholder: "es. Mario Rossi" %>
|
||||
|
||||
<div data-roster-role-fields>
|
||||
<div data-roster-role-player <%= "hidden" unless is_player %>>
|
||||
<%= label_tag "team_roster_member[role_label]", "Ruolo in campo" %>
|
||||
<%= select_tag "team_roster_member[role_label]", roster_player_role_options(player_role),
|
||||
data: { roster_role_player: true }, disabled: !is_player %>
|
||||
</div>
|
||||
<div data-roster-role-other <%= "hidden" if is_player %>>
|
||||
<%= label_tag "team_roster_member[role_label_other]", "Ruolo (opzionale)" %>
|
||||
<%= text_field_tag "team_roster_member[role_label_other]", (member.role_label unless is_player),
|
||||
placeholder: "es. Capo allenatore, Presidente, Massaggiatore",
|
||||
data: { roster_role_other: true }, disabled: is_player %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div data-roster-jersey-row <%= "hidden" unless is_player %>>
|
||||
<%= label_tag "team_roster_member[jersey_number]", "Numero maglia" %>
|
||||
<%= number_field_tag "team_roster_member[jersey_number]", member.jersey_number, min: 1, max: 99,
|
||||
placeholder: "es. 7", data: { roster_jersey: true }, disabled: !is_player %>
|
||||
</div>
|
||||
|
||||
<%= label_tag "team_roster_member[bio]", "Descrizione breve" %>
|
||||
<%= text_area_tag "team_roster_member[bio]", member.bio, rows: 3, placeholder: "Breve presentazione per la scheda…" %>
|
||||
|
||||
<%= label_tag "team_roster_member[photo_file]", "Foto" %>
|
||||
<% if member.photo_url.present? %>
|
||||
<div class="roster-form-photo-preview"><%= roster_person_avatar(member, team, size: 72) %></div>
|
||||
<% end %>
|
||||
<%= file_field_tag "team_roster_member[photo_file]", accept: "image/png,image/jpeg,image/webp" %>
|
||||
<p class="branding-hint">Senza foto verrà mostrato un avatar con i colori della squadra.</p>
|
||||
|
||||
<%= submit_tag (member.persisted? ? "Salva scheda" : "Aggiungi all'organico"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
29
backend/app/views/shared/_roster_person_card.html.erb
Normal file
29
backend/app/views/shared/_roster_person_card.html.erb
Normal file
@@ -0,0 +1,29 @@
|
||||
<% person = local_assigns[:person] %>
|
||||
<% team = local_assigns[:team] %>
|
||||
<% editable = local_assigns.fetch(:editable, false) %>
|
||||
<% compact = local_assigns.fetch(:compact, false) %>
|
||||
|
||||
<article class="roster-card<%= " roster-card--compact" if compact %>">
|
||||
<div class="roster-card__avatar">
|
||||
<%= roster_person_avatar(person, team, size: 72) %>
|
||||
</div>
|
||||
<div class="roster-card__body">
|
||||
<h3 class="roster-card__name">
|
||||
<%= person.full_name %>
|
||||
<% if person.category == "player" && person.jersey_number.present? %>
|
||||
<span class="roster-card__number">#<%= person.jersey_number %></span>
|
||||
<% end %>
|
||||
</h3>
|
||||
<p class="roster-card__role"><%= person.display_role %></p>
|
||||
<% if person.bio.present? %>
|
||||
<p class="roster-card__bio"><%= person.bio %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if editable %>
|
||||
<div class="roster-card__actions">
|
||||
<%= link_to "Modifica", edit_public_team_roster_member_path(team, person), class: "roster-card__btn" %>
|
||||
<%= button_to "Rimuovi", public_team_roster_member_path(team, person), method: :delete, class: "roster-card__btn roster-card__btn--danger",
|
||||
form: { data: { turbo_confirm: "Rimuovere #{person.full_name} dall'organico?" } } %>
|
||||
</div>
|
||||
<% end %>
|
||||
</article>
|
||||
12
backend/app/views/shared/_stripe_secure_payment.html.erb
Normal file
12
backend/app/views/shared/_stripe_secure_payment.html.erb
Normal file
@@ -0,0 +1,12 @@
|
||||
<%# locals: (compact: false) %>
|
||||
<div class="stripe-secure<%= " stripe-secure--compact" if local_assigns.fetch(:compact, false) %>">
|
||||
<i class="fa-solid fa-lock" aria-hidden="true"></i>
|
||||
<p>
|
||||
<% if local_assigns.fetch(:compact, false) %>
|
||||
Pagamenti premium elaborati in modo sicuro tramite <strong>Stripe</strong>.
|
||||
<% else %>
|
||||
I pagamenti dei piani premium sono elaborati in modo <strong>sicuro</strong> tramite
|
||||
<strong>Stripe</strong> (certificazione PCI DSS). Match Live TV non memorizza i numeri completi della carta.
|
||||
<% end %>
|
||||
</p>
|
||||
</div>
|
||||
@@ -43,8 +43,8 @@ Rails.application.configure do
|
||||
|
||||
config.action_mailer.perform_caching = false
|
||||
config.action_mailer.default_url_options = {
|
||||
host: URI.parse(ENV.fetch("APP_PUBLIC_URL", "https://matchlivetv.eminux.it")).host,
|
||||
protocol: URI.parse(ENV.fetch("APP_PUBLIC_URL", "https://matchlivetv.eminux.it")).scheme
|
||||
host: URI.parse(ENV.fetch("APP_PUBLIC_URL", "https://www.matchlivetv.it")).host,
|
||||
protocol: URI.parse(ENV.fetch("APP_PUBLIC_URL", "https://www.matchlivetv.it")).scheme
|
||||
}
|
||||
config.action_mailer.raise_delivery_errors = true
|
||||
|
||||
|
||||
@@ -64,4 +64,8 @@ Rails.application.configure do
|
||||
|
||||
# Raise error when a before_action's only/except options reference missing actions.
|
||||
config.action_controller.raise_on_missing_callback_actions = true
|
||||
|
||||
config.hosts.clear
|
||||
config.hosts << "www.example.com"
|
||||
config.action_dispatch.host_authorization = { exclude: ->(_request) { true } }
|
||||
end
|
||||
|
||||
@@ -34,6 +34,7 @@ Rails.application.routes.draw do
|
||||
post :telemetry
|
||||
post :pairing_token
|
||||
post :claim_pairing
|
||||
post :regia_link
|
||||
post :network_test
|
||||
get :youtube_stats
|
||||
end
|
||||
@@ -62,7 +63,12 @@ Rails.application.routes.draw do
|
||||
root to: "dashboard#index"
|
||||
get "metrics", to: "dashboard#metrics"
|
||||
resources :teams, only: %i[index show]
|
||||
resources :sessions, only: %i[index show]
|
||||
resources :clubs, only: [] do
|
||||
resources :billing_invoices, only: %i[index new create], controller: "billing_invoices"
|
||||
end
|
||||
resources :sessions, only: %i[index show] do
|
||||
member { post :stop }
|
||||
end
|
||||
end
|
||||
|
||||
get "hls/*path", to: "hls_proxy#show", format: false, constraints: { path: /.+/ }
|
||||
@@ -72,6 +78,12 @@ Rails.application.routes.draw do
|
||||
get "live/:id/status.json", to: "public/live#status", as: :public_live_status
|
||||
get "replay/:id", to: "public/replay#show", as: :public_replay
|
||||
|
||||
get "regia/:token", to: "public/regia#show", as: :public_regia
|
||||
get "regia/:token/status.json", to: "public/regia#status", as: :public_regia_status
|
||||
patch "regia/:token/score", to: "public/regia#score", as: :public_regia_score
|
||||
post "regia/:token/pause", to: "public/regia#pause", as: :public_regia_pause
|
||||
post "regia/:token/stop", to: "public/regia#stop", as: :public_regia_stop
|
||||
|
||||
root to: "public/pages#home"
|
||||
get "sitemap.xml", to: "public/sitemap#show", defaults: { format: :xml }
|
||||
|
||||
@@ -98,14 +110,26 @@ Rails.application.routes.draw do
|
||||
get "clubs/:id/edit", to: "clubs#edit", as: :edit_club
|
||||
patch "clubs/:id", to: "clubs#update"
|
||||
get "clubs/:id/billing", to: "clubs#billing", as: :club_billing
|
||||
get "clubs/:id/billing/profile", to: "club_billing#profile", as: :club_billing_profile
|
||||
patch "clubs/:id/billing/profile", to: "club_billing#update_profile"
|
||||
post "clubs/:id/billing/sync_payments", to: "club_billing#sync_payments", as: :club_billing_sync_payments
|
||||
get "clubs/:id/billing/invoices/:invoice_id", to: "club_billing#download_invoice", as: :club_billing_invoice
|
||||
get "clubs/:id/checkout", to: "clubs#checkout", as: :club_checkout
|
||||
post "clubs/:id/portal", to: "clubs#portal", as: :club_portal
|
||||
get "clubs/:club_id/matches/new", to: "club_matches#new", as: :new_club_match
|
||||
post "clubs/:club_id/matches", to: "club_matches#create", as: :club_matches
|
||||
get "teams/new", to: redirect("/clubs/new")
|
||||
get "clubs/:club_id/teams/new", to: "teams#new", as: :new_club_team
|
||||
post "clubs/:club_id/teams", to: "teams#create", as: :club_teams
|
||||
get "teams/:id/dashboard", to: "teams#dashboard", as: :team_dashboard
|
||||
get "teams/:id/dettagli", to: "teams#details", as: :team_details
|
||||
get "teams/:id/dashboard", to: "teams#dashboard"
|
||||
get "teams/:id/roster", to: "teams#roster"
|
||||
get "teams/:id/edit", to: "teams#edit", as: :edit_team
|
||||
patch "teams/:id", to: "teams#update"
|
||||
patch "teams/:id", to: "teams#update", as: :team
|
||||
resources :teams, only: [] do
|
||||
resources :roster_members, only: %i[create edit update destroy], controller: "team_roster_members"
|
||||
resources :matches, only: %i[index new create edit update destroy], controller: "matches"
|
||||
end
|
||||
get "teams/:id/invite", to: "teams#invite", as: :team_invite
|
||||
post "teams/:id/invite", to: "teams#create_invitation"
|
||||
post "teams/:id/staff/self", to: "teams#assign_self_staff", as: :team_assign_self_staff
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
class AddTeamProfileAndRoster < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :teams, :description, :text
|
||||
|
||||
create_table :team_roster_members, id: :uuid do |t|
|
||||
t.references :team, null: false, foreign_key: true, type: :uuid
|
||||
t.string :category, null: false, default: "player"
|
||||
t.string :full_name, null: false
|
||||
t.string :role_label
|
||||
t.integer :jersey_number
|
||||
t.text :bio
|
||||
t.integer :position, null: false, default: 0
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :team_roster_members, %i[team_id category position]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,55 @@
|
||||
class AddClubBillingAndDocuments < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
change_table :clubs, bulk: true do |t|
|
||||
t.string :billing_entity_type, default: "company", null: false
|
||||
t.string :billing_legal_name
|
||||
t.string :billing_vat_number
|
||||
t.string :billing_fiscal_code
|
||||
t.string :billing_email
|
||||
t.string :billing_phone
|
||||
t.string :billing_address_line
|
||||
t.string :billing_city
|
||||
t.string :billing_province, limit: 2
|
||||
t.string :billing_postal_code
|
||||
t.string :billing_country, default: "IT", null: false
|
||||
t.string :billing_recipient_code, limit: 7
|
||||
t.string :billing_pec
|
||||
end
|
||||
|
||||
create_table :billing_payments, id: :uuid do |t|
|
||||
t.references :club, null: false, foreign_key: true, type: :uuid
|
||||
t.string :stripe_invoice_id
|
||||
t.string :stripe_payment_intent_id
|
||||
t.string :stripe_charge_id
|
||||
t.integer :amount_cents, null: false
|
||||
t.string :currency, default: "eur", null: false
|
||||
t.string :status, default: "paid", null: false
|
||||
t.string :plan_slug
|
||||
t.string :description
|
||||
t.datetime :paid_at
|
||||
t.string :invoice_pdf_url
|
||||
t.string :receipt_url
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :billing_payments, :stripe_invoice_id, unique: true, where: "stripe_invoice_id IS NOT NULL"
|
||||
add_index :billing_payments, [:club_id, :paid_at]
|
||||
|
||||
create_table :billing_invoices, id: :uuid do |t|
|
||||
t.references :club, null: false, foreign_key: true, type: :uuid
|
||||
t.references :billing_payment, foreign_key: true, type: :uuid
|
||||
t.string :number, null: false
|
||||
t.date :issued_on, null: false
|
||||
t.integer :amount_cents, null: false
|
||||
t.string :currency, default: "eur", null: false
|
||||
t.string :status, default: "issued", null: false
|
||||
t.string :source, default: "aruba", null: false
|
||||
t.string :aruba_document_id
|
||||
t.text :notes
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :billing_invoices, [:club_id, :number], unique: true
|
||||
add_index :billing_invoices, [:club_id, :issued_on]
|
||||
end
|
||||
end
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user