From f4b7be0f80810950da1fe6fb0a7fe4f43774712d Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Fri, 29 May 2026 07:23:13 +0200 Subject: [PATCH] Billing Stripe, link regia mobile e staff solo trasmissione. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0). Co-authored-by: Cursor --- README.md | 2 +- .../channels/application_cable/connection.rb | 8 +- backend/app/channels/session_channel.rb | 20 +- .../admin/billing_invoices_controller.rb | 50 ++ .../controllers/admin/sessions_controller.rb | 14 + .../controllers/api/v1/matches_controller.rb | 6 +- .../api/v1/stream_sessions_controller.rb | 17 +- .../controllers/api/v1/teams_controller.rb | 11 +- .../public/club_billing_controller.rb | 70 +++ .../public/club_matches_controller.rb | 85 +++ .../controllers/public/clubs_controller.rb | 48 +- .../public/invitations_controller.rb | 2 +- .../app/controllers/public/live_controller.rb | 33 +- .../controllers/public/matches_controller.rb | 102 ++++ .../controllers/public/pages_controller.rb | 14 + .../controllers/public/regia_controller.rb | 82 +++ .../public/registrations_controller.rb | 2 +- .../controllers/public/sessions_controller.rb | 2 +- .../public/team_roster_members_controller.rb | 63 +++ .../controllers/public/teams_controller.rb | 63 ++- backend/app/helpers/public/billing_helper.rb | 29 ++ backend/app/helpers/public/live_helper.rb | 22 + backend/app/helpers/roster_helper.rb | 32 ++ backend/app/helpers/staff_helper.rb | 9 + backend/app/models/billing/invoice.rb | 43 ++ backend/app/models/billing/payment.rb | 25 + backend/app/models/club.rb | 3 + .../models/concerns/club_billing_profile.rb | 47 ++ backend/app/models/match.rb | 38 +- backend/app/models/plan.rb | 8 + backend/app/models/stream_session.rb | 4 +- backend/app/models/team.rb | 22 + backend/app/models/team_invitation.rb | 2 +- backend/app/models/team_roster_member.rb | 87 ++++ backend/app/models/user.rb | 45 ++ .../services/billing/stripe/change_plan.rb | 76 +++ .../billing/stripe/checkout_session.rb | 8 +- .../services/billing/stripe/record_payment.rb | 90 ++++ .../billing/stripe/subscription_period.rb | 27 + .../services/billing/stripe/sync_payments.rb | 36 ++ .../billing/stripe/webhook_handler.rb | 9 +- backend/app/services/scoring/apply_action.rb | 110 ++++ backend/app/services/scoring/rules.rb | 46 ++ backend/app/services/sessions/regia_access.rb | 65 +++ backend/app/services/sessions/stop.rb | 1 + backend/app/services/teams/entitlements.rb | 32 +- .../app/services/teams/staff_assignment.rb | 62 +-- backend/app/services/teams/staff_coverage.rb | 35 ++ .../admin/billing_invoices/index.html.erb | 23 + .../views/admin/billing_invoices/new.html.erb | 36 ++ .../app/views/admin/dashboard/index.html.erb | 5 +- .../app/views/admin/sessions/show.html.erb | 9 + backend/app/views/admin/teams/show.html.erb | 3 + backend/app/views/layouts/marketing.html.erb | 5 +- .../app/views/layouts/marketing_live.html.erb | 4 +- backend/app/views/layouts/public.html.erb | 2 +- backend/app/views/layouts/regia.html.erb | 16 + .../public/club_billing/profile.html.erb | 18 + .../views/public/club_matches/new.html.erb | 35 ++ .../app/views/public/clubs/billing.html.erb | 42 +- backend/app/views/public/clubs/edit.html.erb | 1 + backend/app/views/public/clubs/new.html.erb | 4 +- backend/app/views/public/clubs/show.html.erb | 8 +- .../views/public/invitations/show.html.erb | 2 +- backend/app/views/public/live/index.html.erb | 59 ++- backend/app/views/public/live/show.html.erb | 7 +- .../app/views/public/matches/_form.html.erb | 4 + .../public/matches/_schedule_fields.html.erb | 13 + .../app/views/public/matches/edit.html.erb | 14 + .../app/views/public/matches/index.html.erb | 77 +++ backend/app/views/public/matches/new.html.erb | 14 + .../app/views/public/pages/pallavolo.html.erb | 2 +- .../app/views/public/pages/pricing.html.erb | 61 +-- backend/app/views/public/pages/terms.html.erb | 2 +- .../public/password_resets/edit.html.erb | 17 +- backend/app/views/public/regia/show.html.erb | 97 ++++ .../views/public/registrations/new.html.erb | 29 +- .../app/views/public/sessions/new.html.erb | 17 +- .../public/team_roster_members/edit.html.erb | 8 + .../public/teams/_streaming_staff.html.erb | 100 ++++ .../app/views/public/teams/billing.html.erb | 3 +- .../app/views/public/teams/dashboard.html.erb | 117 ----- .../app/views/public/teams/details.html.erb | 101 ++++ backend/app/views/public/teams/edit.html.erb | 16 +- .../app/views/public/teams/invite.html.erb | 35 +- .../views/shared/_billing_documents.html.erb | 94 ++++ .../shared/_billing_profile_fields.html.erb | 65 +++ .../views/shared/_branding_fields.html.erb | 75 ++- .../shared/_club_subscription_status.html.erb | 19 + .../app/views/shared/_input_toggle.html.erb | 22 + .../views/shared/_marketing_footer.html.erb | 3 + .../app/views/shared/_marketing_nav.html.erb | 2 +- backend/app/views/shared/_plan_cards.html.erb | 55 ++ .../views/shared/_roster_member_form.html.erb | 50 ++ .../views/shared/_roster_person_card.html.erb | 29 ++ .../shared/_stripe_secure_payment.html.erb | 12 + backend/config/environments/production.rb | 4 +- backend/config/environments/test.rb | 4 + backend/config/routes.rb | 30 +- ...60527180000_add_team_profile_and_roster.rb | 19 + ...28100000_add_club_billing_and_documents.rb | 55 ++ ...0000_add_regia_token_to_stream_sessions.rb | 7 + backend/db/schema.rb | 76 ++- backend/public/admin.css | 27 + backend/public/branding-form.js | 129 +++++ backend/public/live.css | 37 +- backend/public/marketing.css | 486 +++++++++++++++++- backend/public/password-toggle.js | 37 ++ backend/public/regia.css | 217 ++++++++ backend/public/regia.js | 252 +++++++++ backend/public/robots.txt | 4 +- backend/public/roster-form.js | 38 ++ .../helpers/public/billing_helper_spec.rb | 38 ++ backend/spec/models/billing/invoice_spec.rb | 27 + .../spec/models/club_billing_profile_spec.rb | 29 ++ backend/spec/rails_helper.rb | 6 +- .../spec/requests/public/club_billing_spec.rb | 62 +++ backend/spec/requests/public/regia_spec.rb | 25 + .../billing/stripe/change_plan_spec.rb | 57 ++ .../billing/stripe/record_payment_spec.rb | 37 ++ .../services/scoring/apply_action_spec.rb | 15 + .../spec/services/teams/entitlements_spec.rb | 46 +- .../services/teams/staff_assignment_spec.rb | 32 +- .../webhooks/mediamtx_handler_spec.rb | 3 +- docs/PRODUCT_PREMIUM.md | 6 +- infra/.env.example | 6 + infra/docker-compose.prod.yml | 4 + infra/docker-compose.yml | 14 +- mobile/lib/app/router.dart | 8 - mobile/lib/core/team_selection_storage.dart | 22 + .../lib/features/archive/archive_screen.dart | 2 +- .../features/camera_mode/camera_screen.dart | 124 ++--- .../controller_mode/controller_landscape.dart | 188 ------- .../controller_mode/controller_portrait.dart | 187 ------- .../controller_mode/controller_screen.dart | 424 --------------- .../lib/features/matches/matches_screen.dart | 39 +- .../matches/resume_session_sheet.dart | 17 +- .../matches/schedule_match_sheet.dart | 21 +- mobile/lib/features/matches/team_picker.dart | 125 +++++ .../lib/features/matches/team_providers.dart | 50 +- .../setup_wizard/step_network_test.dart | 34 +- .../lib/features/setup_wizard/step_roles.dart | 238 --------- .../features/setup_wizard/wizard_shell.dart | 19 +- mobile/lib/providers/auth_provider.dart | 2 + mobile/lib/providers/session_provider.dart | 139 +++-- mobile/lib/shared/api_client.dart | 7 + mobile/lib/shared/models/stream_session.dart | 17 + mobile/lib/shared/models/team.dart | 21 +- mobile/lib/shared/regia_share.dart | 26 + .../shared/widgets/camera_score_controls.dart | 262 +--------- .../Flutter/GeneratedPluginRegistrant.swift | 2 + mobile/pubspec.yaml | 3 +- .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + scripts/run_mobile_android_prod.sh | 2 +- scripts/test_billing_flow.sh | 115 +++++ 156 files changed, 5033 insertions(+), 2033 deletions(-) create mode 100644 backend/app/controllers/admin/billing_invoices_controller.rb create mode 100644 backend/app/controllers/public/club_billing_controller.rb create mode 100644 backend/app/controllers/public/club_matches_controller.rb create mode 100644 backend/app/controllers/public/matches_controller.rb create mode 100644 backend/app/controllers/public/regia_controller.rb create mode 100644 backend/app/controllers/public/team_roster_members_controller.rb create mode 100644 backend/app/helpers/public/billing_helper.rb create mode 100644 backend/app/helpers/roster_helper.rb create mode 100644 backend/app/helpers/staff_helper.rb create mode 100644 backend/app/models/billing/invoice.rb create mode 100644 backend/app/models/billing/payment.rb create mode 100644 backend/app/models/concerns/club_billing_profile.rb create mode 100644 backend/app/models/team_roster_member.rb create mode 100644 backend/app/services/billing/stripe/change_plan.rb create mode 100644 backend/app/services/billing/stripe/record_payment.rb create mode 100644 backend/app/services/billing/stripe/subscription_period.rb create mode 100644 backend/app/services/billing/stripe/sync_payments.rb create mode 100644 backend/app/services/scoring/apply_action.rb create mode 100644 backend/app/services/scoring/rules.rb create mode 100644 backend/app/services/sessions/regia_access.rb create mode 100644 backend/app/services/teams/staff_coverage.rb create mode 100644 backend/app/views/admin/billing_invoices/index.html.erb create mode 100644 backend/app/views/admin/billing_invoices/new.html.erb create mode 100644 backend/app/views/layouts/regia.html.erb create mode 100644 backend/app/views/public/club_billing/profile.html.erb create mode 100644 backend/app/views/public/club_matches/new.html.erb create mode 100644 backend/app/views/public/matches/_form.html.erb create mode 100644 backend/app/views/public/matches/_schedule_fields.html.erb create mode 100644 backend/app/views/public/matches/edit.html.erb create mode 100644 backend/app/views/public/matches/index.html.erb create mode 100644 backend/app/views/public/matches/new.html.erb create mode 100644 backend/app/views/public/regia/show.html.erb create mode 100644 backend/app/views/public/team_roster_members/edit.html.erb create mode 100644 backend/app/views/public/teams/_streaming_staff.html.erb delete mode 100644 backend/app/views/public/teams/dashboard.html.erb create mode 100644 backend/app/views/public/teams/details.html.erb create mode 100644 backend/app/views/shared/_billing_documents.html.erb create mode 100644 backend/app/views/shared/_billing_profile_fields.html.erb create mode 100644 backend/app/views/shared/_club_subscription_status.html.erb create mode 100644 backend/app/views/shared/_input_toggle.html.erb create mode 100644 backend/app/views/shared/_plan_cards.html.erb create mode 100644 backend/app/views/shared/_roster_member_form.html.erb create mode 100644 backend/app/views/shared/_roster_person_card.html.erb create mode 100644 backend/app/views/shared/_stripe_secure_payment.html.erb create mode 100644 backend/db/migrate/20260527180000_add_team_profile_and_roster.rb create mode 100644 backend/db/migrate/20260528100000_add_club_billing_and_documents.rb create mode 100644 backend/db/migrate/20260529120000_add_regia_token_to_stream_sessions.rb create mode 100644 backend/public/branding-form.js create mode 100644 backend/public/password-toggle.js create mode 100644 backend/public/regia.css create mode 100644 backend/public/regia.js create mode 100644 backend/public/roster-form.js create mode 100644 backend/spec/helpers/public/billing_helper_spec.rb create mode 100644 backend/spec/models/billing/invoice_spec.rb create mode 100644 backend/spec/models/club_billing_profile_spec.rb create mode 100644 backend/spec/requests/public/club_billing_spec.rb create mode 100644 backend/spec/requests/public/regia_spec.rb create mode 100644 backend/spec/services/billing/stripe/change_plan_spec.rb create mode 100644 backend/spec/services/billing/stripe/record_payment_spec.rb create mode 100644 backend/spec/services/scoring/apply_action_spec.rb create mode 100644 mobile/lib/core/team_selection_storage.dart delete mode 100644 mobile/lib/features/controller_mode/controller_landscape.dart delete mode 100644 mobile/lib/features/controller_mode/controller_portrait.dart delete mode 100644 mobile/lib/features/controller_mode/controller_screen.dart create mode 100644 mobile/lib/features/matches/team_picker.dart delete mode 100644 mobile/lib/features/setup_wizard/step_roles.dart create mode 100644 mobile/lib/shared/regia_share.dart create mode 100755 scripts/test_billing_flow.sh diff --git a/README.md b/README.md index d902b54..bf87585 100644 --- a/README.md +++ b/README.md @@ -66,5 +66,5 @@ Configura `API_BASE_URL` in `lib/core/config.dart` (default `http://10.0.2.2:300 ```bash ./scripts/run_mobile_android_prod.sh -# API: https://matchlivetv.eminux.it +# API: https://www.matchlivetv.it ``` diff --git a/backend/app/channels/application_cable/connection.rb b/backend/app/channels/application_cable/connection.rb index e1044a9..0e0ea46 100644 --- a/backend/app/channels/application_cable/connection.rb +++ b/backend/app/channels/application_cable/connection.rb @@ -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 diff --git a/backend/app/channels/session_channel.rb b/backend/app/channels/session_channel.rb index 1fe4381..dbf30e5 100644 --- a/backend/app/channels/session_channel.rb +++ b/backend/app/channels/session_channel.rb @@ -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 diff --git a/backend/app/controllers/admin/billing_invoices_controller.rb b/backend/app/controllers/admin/billing_invoices_controller.rb new file mode 100644 index 0000000..1eadf23 --- /dev/null +++ b/backend/app/controllers/admin/billing_invoices_controller.rb @@ -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 diff --git a/backend/app/controllers/admin/sessions_controller.rb b/backend/app/controllers/admin/sessions_controller.rb index cdf87e2..6445242 100644 --- a/backend/app/controllers/admin/sessions_controller.rb +++ b/backend/app/controllers/admin/sessions_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/matches_controller.rb b/backend/app/controllers/api/v1/matches_controller.rb index 0d78f7a..9238aa9 100644 --- a/backend/app/controllers/api/v1/matches_controller.rb +++ b/backend/app/controllers/api/v1/matches_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/stream_sessions_controller.rb b/backend/app/controllers/api/v1/stream_sessions_controller.rb index bfb6d61..e5483a6 100644 --- a/backend/app/controllers/api/v1/stream_sessions_controller.rb +++ b/backend/app/controllers/api/v1/stream_sessions_controller.rb @@ -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 diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index fab2c89..39d1c27 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -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| diff --git a/backend/app/controllers/public/club_billing_controller.rb b/backend/app/controllers/public/club_billing_controller.rb new file mode 100644 index 0000000..bbe9481 --- /dev/null +++ b/backend/app/controllers/public/club_billing_controller.rb @@ -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 diff --git a/backend/app/controllers/public/club_matches_controller.rb b/backend/app/controllers/public/club_matches_controller.rb new file mode 100644 index 0000000..804e8f8 --- /dev/null +++ b/backend/app/controllers/public/club_matches_controller.rb @@ -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 diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index c438272..47e1fbd 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -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 diff --git a/backend/app/controllers/public/invitations_controller.rb b/backend/app/controllers/public/invitations_controller.rb index 5710a1e..066d5c3 100644 --- a/backend/app/controllers/public/invitations_controller.rb +++ b/backend/app/controllers/public/invitations_controller.rb @@ -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" diff --git a/backend/app/controllers/public/live_controller.rb b/backend/app/controllers/public/live_controller.rb index 058f9ce..ab37b0e 100644 --- a/backend/app/controllers/public/live_controller.rb +++ b/backend/app/controllers/public/live_controller.rb @@ -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, diff --git a/backend/app/controllers/public/matches_controller.rb b/backend/app/controllers/public/matches_controller.rb new file mode 100644 index 0000000..c271085 --- /dev/null +++ b/backend/app/controllers/public/matches_controller.rb @@ -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 diff --git a/backend/app/controllers/public/pages_controller.rb b/backend/app/controllers/public/pages_controller.rb index 218151b..24891ff 100644 --- a/backend/app/controllers/public/pages_controller.rb +++ b/backend/app/controllers/public/pages_controller.rb @@ -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 diff --git a/backend/app/controllers/public/regia_controller.rb b/backend/app/controllers/public/regia_controller.rb new file mode 100644 index 0000000..8c91c88 --- /dev/null +++ b/backend/app/controllers/public/regia_controller.rb @@ -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 diff --git a/backend/app/controllers/public/registrations_controller.rb b/backend/app/controllers/public/registrations_controller.rb index d6c0c93..76cf638 100644 --- a/backend/app/controllers/public/registrations_controller.rb +++ b/backend/app/controllers/public/registrations_controller.rb @@ -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à." diff --git a/backend/app/controllers/public/sessions_controller.rb b/backend/app/controllers/public/sessions_controller.rb index 8240ef6..5c31cbc 100644 --- a/backend/app/controllers/public/sessions_controller.rb +++ b/backend/app/controllers/public/sessions_controller.rb @@ -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 diff --git a/backend/app/controllers/public/team_roster_members_controller.rb b/backend/app/controllers/public/team_roster_members_controller.rb new file mode 100644 index 0000000..f87a902 --- /dev/null +++ b/backend/app/controllers/public/team_roster_members_controller.rb @@ -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 diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 49a0127..4969703 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -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? diff --git a/backend/app/helpers/public/billing_helper.rb b/backend/app/helpers/public/billing_helper.rb new file mode 100644 index 0000000..09200ed --- /dev/null +++ b/backend/app/helpers/public/billing_helper.rb @@ -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 diff --git a/backend/app/helpers/public/live_helper.rb b/backend/app/helpers/public/live_helper.rb index 0e8ccd4..e5e50e5 100644 --- a/backend/app/helpers/public/live_helper.rb +++ b/backend/app/helpers/public/live_helper.rb @@ -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 diff --git a/backend/app/helpers/roster_helper.rb b/backend/app/helpers/roster_helper.rb new file mode 100644 index 0000000..804b4b2 --- /dev/null +++ b/backend/app/helpers/roster_helper.rb @@ -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 diff --git a/backend/app/helpers/staff_helper.rb b/backend/app/helpers/staff_helper.rb new file mode 100644 index 0000000..a777f79 --- /dev/null +++ b/backend/app/helpers/staff_helper.rb @@ -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 diff --git a/backend/app/models/billing/invoice.rb b/backend/app/models/billing/invoice.rb new file mode 100644 index 0000000..2d53468 --- /dev/null +++ b/backend/app/models/billing/invoice.rb @@ -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 diff --git a/backend/app/models/billing/payment.rb b/backend/app/models/billing/payment.rb new file mode 100644 index 0000000..68fe8e1 --- /dev/null +++ b/backend/app/models/billing/payment.rb @@ -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 diff --git a/backend/app/models/club.rb b/backend/app/models/club.rb index 198b60d..e5c115d 100644 --- a/backend/app/models/club.rb +++ b/backend/app/models/club.rb @@ -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 diff --git a/backend/app/models/concerns/club_billing_profile.rb b/backend/app/models/concerns/club_billing_profile.rb new file mode 100644 index 0000000..4cb09f5 --- /dev/null +++ b/backend/app/models/concerns/club_billing_profile.rb @@ -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 diff --git a/backend/app/models/match.rb b/backend/app/models/match.rb index b67cb52..60f6049 100644 --- a/backend/app/models/match.rb +++ b/backend/app/models/match.rb @@ -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 ) } diff --git a/backend/app/models/plan.rb b/backend/app/models/plan.rb index 00eb601..74b2045 100644 --- a/backend/app/models/plan.rb +++ b/backend/app/models/plan.rb @@ -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? diff --git a/backend/app/models/stream_session.rb b/backend/app/models/stream_session.rb index 6b17f40..b8d9a96 100644 --- a/backend/app/models/stream_session.rb +++ b/backend/app/models/stream_session.rb @@ -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 ) } diff --git a/backend/app/models/team.rb b/backend/app/models/team.rb index 0c0dce8..814263f 100644 --- a/backend/app/models/team.rb +++ b/backend/app/models/team.rb @@ -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 diff --git a/backend/app/models/team_invitation.rb b/backend/app/models/team_invitation.rb index 846c5b0..b09ca3e 100644 --- a/backend/app/models/team_invitation.rb +++ b/backend/app/models/team_invitation.rb @@ -1,5 +1,5 @@ class TeamInvitation < ApplicationRecord - STAFF_KINDS = %w[transmission regia].freeze + STAFF_KINDS = %w[transmission].freeze belongs_to :team diff --git a/backend/app/models/team_roster_member.rb b/backend/app/models/team_roster_member.rb new file mode 100644 index 0000000..3b358ca --- /dev/null +++ b/backend/app/models/team_roster_member.rb @@ -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 diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 6c0180f..9e26314 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -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 diff --git a/backend/app/services/billing/stripe/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb new file mode 100644 index 0000000..c4c99b5 --- /dev/null +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -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 diff --git a/backend/app/services/billing/stripe/checkout_session.rb b/backend/app/services/billing/stripe/checkout_session.rb index 9a257df..1ee4fb3 100644 --- a/backend/app/services/billing/stripe/checkout_session.rb +++ b/backend/app/services/billing/stripe/checkout_session.rb @@ -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 diff --git a/backend/app/services/billing/stripe/record_payment.rb b/backend/app/services/billing/stripe/record_payment.rb new file mode 100644 index 0000000..0c2e4ac --- /dev/null +++ b/backend/app/services/billing/stripe/record_payment.rb @@ -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 diff --git a/backend/app/services/billing/stripe/subscription_period.rb b/backend/app/services/billing/stripe/subscription_period.rb new file mode 100644 index 0000000..57879c0 --- /dev/null +++ b/backend/app/services/billing/stripe/subscription_period.rb @@ -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 diff --git a/backend/app/services/billing/stripe/sync_payments.rb b/backend/app/services/billing/stripe/sync_payments.rb new file mode 100644 index 0000000..be8ede4 --- /dev/null +++ b/backend/app/services/billing/stripe/sync_payments.rb @@ -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 diff --git a/backend/app/services/billing/stripe/webhook_handler.rb b/backend/app/services/billing/stripe/webhook_handler.rb index 36ad5a5..1ffe48f 100644 --- a/backend/app/services/billing/stripe/webhook_handler.rb +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -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? diff --git a/backend/app/services/scoring/apply_action.rb b/backend/app/services/scoring/apply_action.rb new file mode 100644 index 0000000..74a8d21 --- /dev/null +++ b/backend/app/services/scoring/apply_action.rb @@ -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 diff --git a/backend/app/services/scoring/rules.rb b/backend/app/services/scoring/rules.rb new file mode 100644 index 0000000..bebaeda --- /dev/null +++ b/backend/app/services/scoring/rules.rb @@ -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 diff --git a/backend/app/services/sessions/regia_access.rb b/backend/app/services/sessions/regia_access.rb new file mode 100644 index 0000000..81a425f --- /dev/null +++ b/backend/app/services/sessions/regia_access.rb @@ -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 diff --git a/backend/app/services/sessions/stop.rb b/backend/app/services/sessions/stop.rb index 6077fcf..9201342 100644 --- a/backend/app/services/sessions/stop.rb +++ b/backend/app/services/sessions/stop.rb @@ -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 diff --git a/backend/app/services/teams/entitlements.rb b/backend/app/services/teams/entitlements.rb index 67ed8b8..41b2c61 100644 --- a/backend/app/services/teams/entitlements.rb +++ b/backend/app/services/teams/entitlements.rb @@ -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! diff --git a/backend/app/services/teams/staff_assignment.rb b/backend/app/services/teams/staff_assignment.rb index 30538e7..940190a 100644 --- a/backend/app/services/teams/staff_assignment.rb +++ b/backend/app/services/teams/staff_assignment.rb @@ -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 diff --git a/backend/app/services/teams/staff_coverage.rb b/backend/app/services/teams/staff_coverage.rb new file mode 100644 index 0000000..8302475 --- /dev/null +++ b/backend/app/services/teams/staff_coverage.rb @@ -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 diff --git a/backend/app/views/admin/billing_invoices/index.html.erb b/backend/app/views/admin/billing_invoices/index.html.erb new file mode 100644 index 0000000..ab67758 --- /dev/null +++ b/backend/app/views/admin/billing_invoices/index.html.erb @@ -0,0 +1,23 @@ +

Fatture — <%= @club.name %>

+

<%= link_to "← Dashboard", admin_root_path %> · <%= link_to "Nuova fattura", new_admin_club_billing_invoice_path(@club), class: "btn btn-primary" %>

+ +<% if @invoices.any? %> + + + + + + <% @invoices.each do |inv| %> + + + + + + + + <% end %> + +
NumeroDataImportoStatoPDF
<%= inv.number %><%= inv.issued_on %><%= inv.formatted_amount %><%= inv.status %><%= inv.pdf.attached? ? "Sì" : "No" %>
+<% else %> +

Nessuna fattura.

+<% end %> diff --git a/backend/app/views/admin/billing_invoices/new.html.erb b/backend/app/views/admin/billing_invoices/new.html.erb new file mode 100644 index 0000000..e30a097 --- /dev/null +++ b/backend/app/views/admin/billing_invoices/new.html.erb @@ -0,0 +1,36 @@ +

Nuova fattura Aruba — <%= @club.name %>

+

Carica il PDF emesso da Aruba Fatture e collega il pagamento se disponibile.

+ +
+ <%= 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 %> +
+ +

<%= link_to "← Elenco fatture", admin_club_billing_invoices_path(@club) %>

diff --git a/backend/app/views/admin/dashboard/index.html.erb b/backend/app/views/admin/dashboard/index.html.erb index e5fb045..f8ab7b1 100644 --- a/backend/app/views/admin/dashboard/index.html.erb +++ b/backend/app/views/admin/dashboard/index.html.erb @@ -99,7 +99,10 @@ <%= s.match.team.name %> vs <%= s.match.opponent_name %> <%= s.status %> <%= s.started_at&.strftime("%d/%m %H:%M") || "—" %> - <%= link_to "Dettaglio", admin_session_path(s) %> + + <%= 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?" } } %> + <% end %> diff --git a/backend/app/views/admin/sessions/show.html.erb b/backend/app/views/admin/sessions/show.html.erb index 6829e3e..ff039ee 100644 --- a/backend/app/views/admin/sessions/show.html.erb +++ b/backend/app/views/admin/sessions/show.html.erb @@ -1,5 +1,14 @@

Session <%= @session.id %>

Status: <%= @session.status %>

+<% unless @session.terminal? %> +

+ <%= 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." } } %> +

+<% end %>

Match: <%= @session.match.team.name %> vs <%= @session.match.opponent_name %>

<% if @session.youtube_broadcast_id %>

YouTube: Broadcast

diff --git a/backend/app/views/admin/teams/show.html.erb b/backend/app/views/admin/teams/show.html.erb index 13b4a88..102c1c0 100644 --- a/backend/app/views/admin/teams/show.html.erb +++ b/backend/app/views/admin/teams/show.html.erb @@ -1,5 +1,8 @@

<%= @team.name %>

Sport: <%= @team.sport %>

+<% if @team.club %> +

Società: <%= @team.club.name %> · <%= link_to "Fatture società", admin_club_billing_invoices_path(@team.club) %>

+<% end %>

YouTube: <%= @team.youtube_credential ? "Connesso" : link_to("Connetti", "/api/v1/teams/#{@team.id}/youtube/authorize") %>

Partite

diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index 78e4778..97ba051 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -6,7 +6,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= render "shared/marketing_nav" %> @@ -16,5 +16,8 @@ <%= yield %> <%= render "shared/marketing_footer" %> + + + diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index bd4a513..5ef5573 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -6,8 +6,8 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - - + + <%= yield :head %> diff --git a/backend/app/views/layouts/public.html.erb b/backend/app/views/layouts/public.html.erb index e311c46..6d291bf 100644 --- a/backend/app/views/layouts/public.html.erb +++ b/backend/app/views/layouts/public.html.erb @@ -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 %> diff --git a/backend/app/views/layouts/regia.html.erb b/backend/app/views/layouts/regia.html.erb new file mode 100644 index 0000000..35d0906 --- /dev/null +++ b/backend/app/views/layouts/regia.html.erb @@ -0,0 +1,16 @@ + + + + + + + + <%= content_for?(:title) ? yield(:title) : "Regia — Match Live TV" %> + <%= render "shared/meta_tags" %> + + <%= yield :head %> + + + <%= yield %> + + diff --git a/backend/app/views/public/club_billing/profile.html.erb b/backend/app/views/public/club_billing/profile.html.erb new file mode 100644 index 0000000..24c699b --- /dev/null +++ b/backend/app/views/public/club_billing/profile.html.erb @@ -0,0 +1,18 @@ +<% content_for :title, "Dati di fatturazione — #{@club.name}" %> +<% content_for :robots, "noindex, nofollow" %> + +
+

Dati di fatturazione

+

Società: <%= @club.name %>. Inserisci i dati per l’intestazione delle fatture e l’invio tramite SDI o PEC.

+ +
+ <%= 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 %> +
+ +

+ <%= link_to "← Abbonamento", public_club_billing_path(@club) %> +

+
diff --git a/backend/app/views/public/club_matches/new.html.erb b/backend/app/views/public/club_matches/new.html.erb new file mode 100644 index 0000000..30f9bba --- /dev/null +++ b/backend/app/views/public/club_matches/new.html.erb @@ -0,0 +1,35 @@ +<% content_for :title, "Programma partita — #{@club.name}" %> +<% content_for :robots, "noindex, nofollow" %> + +
+

+ <%= link_to "← Dirette e partite", public_live_index_path(club_id: @club.id), class: "back-link" %> +

+

Programma partita

+

+ <%= @club.name %> + <% if @club_admin %> + · Puoi scegliere qualsiasi squadra della società + <% else %> + · Solo le squadre in cui sei responsabile trasmissione + <% end %> +

+ +
+ <%= 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 %> +

<%= @selected_team.name %>

+ <% 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 %> +
+
diff --git a/backend/app/views/public/clubs/billing.html.erb b/backend/app/views/public/clubs/billing.html.erb index e4b6949..8b0bb2d 100644 --- a/backend/app/views/public/clubs/billing.html.erb +++ b/backend/app/views/public/clubs/billing.html.erb @@ -3,44 +3,12 @@

Abbonamento

-

Società: <%= @club.name %> · Piano attuale: <%= @entitlements.plan.name %> (valido per tutte le squadre)

+ <%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription %> -
- <% @plans.each do |plan| %> -
-

<%= plan.name %>

- <% if plan.slug == "free" %> -
€0
- <% elsif plan.slug == "premium_light" %> -
€40/anno
-

oppure €5/mese

- <% else %> -
€200/anno
-

oppure €20/mese

- <% end %> -
    -
  • Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno per squadra
  • -
  • Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno per squadra
  • -
  • Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea (tutta la società)
  • -
  • Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %>
  • -
  • YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %>
  • -
- <% if plan.slug == @entitlements.plan.slug %> - Piano attuale - <% elsif plan.slug == "free" %> -

Contatta il supporto per downgrade.

- <% elsif MatchLiveTv.stripe_enabled? %> - <%= link_to "Attiva #{plan.name}", public_club_checkout_path(@club, plan: plan.slug), class: "btn btn-primary" %> - <% else %> -

Stripe non configurato

- <% end %> -
- <% end %> -
+ <%= render "shared/stripe_secure_payment" %> + <%= render "shared/plan_cards", show_stripe_portal: true %> - <% if @entitlements.premium_active? && MatchLiveTv.stripe_enabled? %> -

<%= button_to "Gestisci su Stripe", public_club_portal_path(@club), class: "btn btn-secondary" %>

- <% end %> + <%= render "shared/billing_documents", club: @club, payments: @payments, invoices: @invoices %> -

<%= link_to "← Società", public_club_path(@club) %>

+

<%= link_to "← Società", public_club_path(@club) %>

diff --git a/backend/app/views/public/clubs/edit.html.erb b/backend/app/views/public/clubs/edit.html.erb index 3ee1a59..4006e17 100644 --- a/backend/app/views/public/clubs/edit.html.erb +++ b/backend/app/views/public/clubs/edit.html.erb @@ -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 %> diff --git a/backend/app/views/public/clubs/new.html.erb b/backend/app/views/public/clubs/new.html.erb index 947a6f5..d712e89 100644 --- a/backend/app/views/public/clubs/new.html.erb +++ b/backend/app/views/public/clubs/new.html.erb @@ -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") %>

Prima squadra

<%= 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 %> diff --git a/backend/app/views/public/clubs/show.html.erb b/backend/app/views/public/clubs/show.html.erb index 7e9da2a..138303b 100644 --- a/backend/app/views/public/clubs/show.html.erb +++ b/backend/app/views/public/clubs/show.html.erb @@ -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" %>

Squadre

@@ -44,7 +44,9 @@ <% end %> - <%= 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) %> @@ -57,5 +59,5 @@ <% end %> -

Ogni squadra ha il proprio staff trasmissione/regia. L’abbonamento vale per tutta la società.

+

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à.

diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb index 191c53f..ceb19f4 100644 --- a/backend/app/views/public/invitations/show.html.erb +++ b/backend/app/views/public/invitations/show.html.erb @@ -4,7 +4,7 @@

Accesso staff — <%= @invitation.team.name %>

-

Sei stato aggiunto allo staff come <%= @invitation.staff_kind == "regia" ? "Regia" : "Trasmissione" %> (<%= @invitation.email %>).

+

Sei stato aggiunto come responsabile trasmissione (<%= @invitation.email %>).

<% if logged_in? %> <%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> <% else %> diff --git a/backend/app/views/public/live/index.html.erb b/backend/app/views/public/live/index.html.erb index c68dd3b..5c73ece 100644 --- a/backend/app/views/public/live/index.html.erb +++ b/backend/app/views/public/live/index.html.erb @@ -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 } : {} %>
-

Dirette e partite in programma

-

- Cerca la tua squadra: trovi le dirette attive e le partite già programmate dall’app. -

+ <% if @club %> +

+ <%= link_to "← #{@club.name}", public_club_path(@club), class: "back-link" %> +

+

Dirette e partite — <%= @club.name %>

+

+ Solo le squadre di questa società: dirette in corso e partite programmate da app o sito. +

+ <% else %> +

Dirette e partite in programma

+

+ Cerca per società, squadra, avversario o luogo: trovi le dirette attive e le partite programmate. +

+ <% end %> + + <% if @club && @can_schedule_match %> +
+ <%= link_to "Programma partita", public_new_club_match_path(@club), class: "btn btn-primary" %> +
+ <% 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 %> " aria-label="Cerca squadra" autocomplete="off" /> <% 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? %>

- Risultati per «<%= h @query %>»: + Risultati per «<%= h @query %>»<%= @club ? " in #{@club.name}" : "" %>: <%= @sessions.size %> in diretta · <%= @upcoming_matches.size %> in programma

<% end %> @@ -37,7 +57,7 @@ <% match = session.match %> <% on_air = @online_paths.include?(session.mediamtx_path_name) %>
-

<%= match.team.name %> vs <%= match.opponent_name %>

+ <%= live_match_card_heading(match) %>

<% if match.location.present? %><%= match.location %> · <% end %> <%= session.platform == "matchlivetv" ? "Match Live TV" : session.platform.capitalize %> @@ -70,11 +90,11 @@ <% if @upcoming_matches.any? %>

Prossime partite

-

Programmate dall’app: la diretta partirà quando lo staff avvierà la trasmissione.

+

Programmate da app o sito: la diretta partirà quando lo staff avvierà la trasmissione.

<% @upcoming_matches.each do |match| %>
-

<%= match.team.name %> vs <%= match.opponent_name %>

+ <%= live_match_card_heading(match) %>

<% if match.location.present? %><%= match.location %> · <% end %> <%= match.category.presence || match.sport&.capitalize %> @@ -96,6 +116,25 @@

Nessuna diretta in questo momento.

Torna all’orario indicato: quando la squadra avvierà lo streaming, la partita comparirà in «In diretta adesso».

+ <% elsif @club %> +
+

Nessuna diretta o partita in programma per <%= @club.name %>.

+

+ <% 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 %> +

+

+ <%= link_to "← Torna alla società", public_club_path(@club), class: "btn btn-secondary" %> +

+
<% else %>
diff --git a/backend/app/views/public/live/show.html.erb b/backend/app/views/public/live/show.html.erb index f689425..501d001 100644 --- a/backend/app/views/public/live/show.html.erb +++ b/backend/app/views/public/live/show.html.erb @@ -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 @@
<%= link_to "← Tutte le dirette", public_live_index_path, class: "back-link" %> -

<%= @match.team.name %> vs <%= @match.opponent_name %>

+<%= live_match_page_heading(@match) %>

<% if @stream_closed %> Diretta chiusa diff --git a/backend/app/views/public/matches/_form.html.erb b/backend/app/views/public/matches/_form.html.erb new file mode 100644 index 0000000..0d093e7 --- /dev/null +++ b/backend/app/views/public/matches/_form.html.erb @@ -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 %> diff --git a/backend/app/views/public/matches/_schedule_fields.html.erb b/backend/app/views/public/matches/_schedule_fields.html.erb new file mode 100644 index 0000000..17b9767 --- /dev/null +++ b/backend/app/views/public/matches/_schedule_fields.html.erb @@ -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 %> + +

+ La diretta non parte da sola: comparirà in app e sul sito. Avvierai lo streaming dal telefono quando sei in campo. +

diff --git a/backend/app/views/public/matches/edit.html.erb b/backend/app/views/public/matches/edit.html.erb new file mode 100644 index 0000000..36e1d15 --- /dev/null +++ b/backend/app/views/public/matches/edit.html.erb @@ -0,0 +1,14 @@ +<% content_for :title, "Modifica partita — #{@team.name}" %> +<% content_for :robots, "noindex, nofollow" %> + +
+

Modifica partita

+

<%= @team.name %> vs <%= @match.opponent_name %>

+
+ <%= render "form", + form_url: public_team_match_path(@team, @match), + form_method: :patch, + submit_label: "Salva modifiche" %> +
+

<%= link_to "← Elenco partite", public_team_matches_path(@team) %>

+
diff --git a/backend/app/views/public/matches/index.html.erb b/backend/app/views/public/matches/index.html.erb new file mode 100644 index 0000000..311c87b --- /dev/null +++ b/backend/app/views/public/matches/index.html.erb @@ -0,0 +1,77 @@ +<% content_for :title, "Partite — #{@team.name}" %> +<% content_for :robots, "noindex, nofollow" %> + +
+ + +
+

Partite — <%= @team.name %>

+

+ 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. +

+
+ +
+ <%= 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" %> +
+ +
+ <% if @matches.any? %> + + + + + + + + + + + + <% @matches.each do |match| %> + + + + + + + + <% end %> + +
AvversarioData e oraLuogoStato
<%= match.opponent_name %> + <% if match.scheduled_at %> + <%= l match.scheduled_at.in_time_zone, format: :long %> + <% else %> + Senza orario + <% end %> + <%= match.location.presence || "—" %> + <% label = match.public_status_label %> + <% if label == "In corso / da riprendere" %> + <%= label %> + <% else %> + <%= label %> + <% end %> + + <%= 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 %> +
+ <% else %> +

+ Nessuna partita in calendario. + <%= link_to "Programma la prima", new_public_team_match_path(@team) %>. +

+ <% end %> +
+
diff --git a/backend/app/views/public/matches/new.html.erb b/backend/app/views/public/matches/new.html.erb new file mode 100644 index 0000000..1016db2 --- /dev/null +++ b/backend/app/views/public/matches/new.html.erb @@ -0,0 +1,14 @@ +<% content_for :title, "Programma partita — #{@team.name}" %> +<% content_for :robots, "noindex, nofollow" %> + +
+

Programma partita

+

<%= @club.name %> · <%= @team.name %>

+
+ <%= render "form", + form_url: public_team_matches_path(@team), + form_method: :post, + submit_label: "Salva in programma" %> +
+

<%= link_to "← Elenco partite", public_team_matches_path(@team) %>

+
diff --git a/backend/app/views/public/pages/pallavolo.html.erb b/backend/app/views/public/pages/pallavolo.html.erb index b636f0e..f6960ce 100644 --- a/backend/app/views/public/pages/pallavolo.html.erb +++ b/backend/app/views/public/pages/pallavolo.html.erb @@ -34,7 +34,7 @@

Pallavolo: punteggio e regole

- 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.

diff --git a/backend/app/views/public/pages/pricing.html.erb b/backend/app/views/public/pages/pricing.html.erb index b0a24f3..25035b8 100644 --- a/backend/app/views/public/pages/pricing.html.erb +++ b/backend/app/views/public/pages/pricing.html.erb @@ -3,60 +3,27 @@ <% content_for :canonical_url, seo_absolute_url(public_prezzi_path) %>
-

Piani per la tua squadra

-

Abbonamento annuale per squadra: lo staff gestisce trasmissione e regia con credenziali dedicate. Pagamento sicuro su Stripe.

+ <% if @club %> +

Piani per la tua società

+ <% if @entitlements %> + <%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription %> + <% else %> +

Società: <%= @club.name %> · <%= link_to "Gestisci abbonamento", public_club_billing_path(@club) %>

+ <% end %> + <% else %> +

Piani per la tua squadra

+

Abbonamento annuale per società: i responsabili trasmissione avviano la diretta dall’app; il punteggio si gestisce con un link condivisibile.

+ <% end %> + <%= render "shared/stripe_secure_payment" %> -
-
-

Free

-
€0
-
    -
  • 1 persona staff / anno per la trasmissione
  • -
  • 1 persona staff / anno per la regia
  • -
  • 1 live alla volta su matchlivetv.eminux.it
  • -
  • Punteggio live e regia da app
  • -
  • No salvataggio / replay
  • -
  • No YouTube
  • -
- <%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %> -
- - - -
-

Premium Full

-
€200/anno
-

oppure €20/mese

-
    -
  • Staff illimitato / anno (trasmissione e regia)
  • -
  • Fino a 10 partite in contemporanea
  • -
  • Collegamento con il canale YouTube della società
  • -
  • Archivio server 90 giorni e download su telefono
  • -
  • Sito società (in arrivo)
  • -
- <%= link_to "Registrati", public_signup_path, class: "btn btn-primary" %> -
-
+ <%= render "shared/plan_cards" %> - - + diff --git a/backend/app/views/public/pages/terms.html.erb b/backend/app/views/public/pages/terms.html.erb index 90d6f77..7cf25ff 100644 --- a/backend/app/views/public/pages/terms.html.erb +++ b/backend/app/views/public/pages/terms.html.erb @@ -24,7 +24,7 @@

2. Chi può usare il servizio

  • Il referente che registra la squadra dichiara di avere almeno 18 anni e poteri per vincolare la società sportiva.
  • -
  • Lo staff invitato (trasmissione, regia) usa credenziali personali e si impegna a custodirle.
  • +
  • I responsabili trasmissione invitati usano credenziali personali e si impegnano a custodirle.
  • Gli spettatori che aprono il link della diretta di solito non necessitano di account.
diff --git a/backend/app/views/public/password_resets/edit.html.erb b/backend/app/views/public/password_resets/edit.html.erb index 4019208..4631501 100644 --- a/backend/app/views/public/password_resets/edit.html.erb +++ b/backend/app/views/public/password_resets/edit.html.erb @@ -6,10 +6,19 @@
<%= 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 %> diff --git a/backend/app/views/public/regia/show.html.erb b/backend/app/views/public/regia/show.html.erb new file mode 100644 index 0000000..cad6e95 --- /dev/null +++ b/backend/app/views/public/regia/show.html.erb @@ -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 %> + + <% end %> +<% end %> + +
" + 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 %>"> + +
+

<%= @team.name %> vs <%= @match.opponent_name %>

+

Regia · Set <%= @score.current_set %>

+ + <%= @stream_closed ? "Terminata" : (@on_air ? "In onda" : "In attesa") %> + +
+ +
+ + +
+ +
+ <% unless @stream_closed %> + + <% end %> +
> + <%= @stream_closed ? "Diretta terminata" : "Anteprima in attesa del segnale…" %> +
+
+ +
+

<%= live_score_sets_label(@score) || "—" %>

+

<%= live_score_points_label(@match, @score) %>

+

> + Parziali: <%= live_score_partials_label(@score) %> +

+ +
+
+
<%= @team.name %>
+
<%= @score.home_points %>
+ + +
+
+ <%= Scoring::Rules.from_match(@match).points_target(@score.current_set) %> pt +
+
+
<%= @match.opponent_name %>
+
<%= @score.away_points %>
+ + +
+
+ +
+ + <% unless @stream_closed %> + + + <% end %> +
+ +
+ + + + diff --git a/backend/app/views/public/registrations/new.html.erb b/backend/app/views/public/registrations/new.html.erb index 2dcd957..a8a502f 100644 --- a/backend/app/views/public/registrations/new.html.erb +++ b/backend/app/views/public/registrations/new.html.erb @@ -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" %>
FreePremium LightPremium Full
Staff trasmissione / anno15Illimitato
Staff regia / anno15Illimitato
Responsabili trasmissione / anno15Illimitato
Partite in contemporanea1310
Live su Match Live TV
YouTubeNoMatch TV LightCanale società
+ <% if can_manage %><% end %> + + <% staff_memberships.each do |ut| %> + + + + + <% if can_manage %> + + <% end %> + + <% end %> + +
NomeEmailRuolo
+ <%= ut.user.name %> + <% if ut.user_id == current_user.id %> (tu)<% end %> + <%= ut.user.email %>Trasmissione + <% 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 %> +
+ <% else %> +

+ Nessun responsabile trasmissione. + <% if can_manage %><%= link_to "Invita qualcuno", public_team_invite_path(team) %>.<% end %> +

+ <% end %> +
+ + <% if can_manage %> +

Inviti in attesa

+
+ <% if pending_invitations.any? %> + + + + <% pending_invitations.each do |inv| %> + + + + + + <% end %> + +
EmailScade
<%= inv.email %><%= l inv.expires_at, format: :short %> + <%= button_to "Annulla", public_team_destroy_invitation_path(team, inv), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem" %> +
+ <% else %> +

Nessuna richiesta in sospeso.

+ <% end %> +
+ <% end %> + + <% if entitlements.can_access_recordings? && recordings.any? %> +

Archivio gare

+
+ + + + <% recordings.each do |rec| %> + + + + + <% end %> + +
PartitaReplay
<%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %><%= link_to "Guarda", rec.replay_url %>
+
+ <% end %> + diff --git a/backend/app/views/public/teams/billing.html.erb b/backend/app/views/public/teams/billing.html.erb index 219bd8e..7420704 100644 --- a/backend/app/views/public/teams/billing.html.erb +++ b/backend/app/views/public/teams/billing.html.erb @@ -20,7 +20,6 @@ <% end %>
  • Staff trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno
  • -
  • Staff regia: <%= plan.max_staff_regia || "illimitato" %> / anno
  • Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea
  • Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %>
  • YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %>
  • @@ -46,5 +45,5 @@

    <%= button_to "Gestisci su Stripe", public_team_portal_path(@team), class: "btn btn-secondary" %>

    <% end %> -

    <%= link_to "← Dashboard", public_team_dashboard_path(@team) %>

    +

    <%= link_to "← Dettagli squadra", public_team_details_path(@team) %>

diff --git a/backend/app/views/public/teams/dashboard.html.erb b/backend/app/views/public/teams/dashboard.html.erb deleted file mode 100644 index 1307d4c..0000000 --- a/backend/app/views/public/teams/dashboard.html.erb +++ /dev/null @@ -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" %> - -
-

<%= link_to @club.name, public_club_path(@club) %> · <%= link_to "Modifica squadra", public_edit_team_path(@team) %>

-

- <% if @team.effective_logo_url.present? %> - <%= image_tag @team.effective_logo_url, alt: "", width: 40, height: 40, style: "border-radius:8px" %> - <% end %> - <%= @team.name %> -

-

- Piano: <%= @entitlements.plan.name %> (società) - · Staff trasmissione: <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> - · Staff regia: <%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %> - · Partite attive: <%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %> -

- -
- <%= 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" %> -
- - <% 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 %> -
-

Il tuo account (<%= current_user.email %>)

-

- Puoi usare le stesse credenziali nell’app mobile. Trasmissione e regia devono essere email diverse — non puoi coprire entrambi i ruoli con questo account. -

-
- <% 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 %> -
-
- <% end %> - -

Staff attivo

-
- <% if @staff_memberships.any? %> - - - - <% @staff_memberships.each do |ut| %> - - - - - - - <% end %> - -
NomeEmailRuolo
- <%= ut.user.name %> - <% if ut.role == "owner" %> (tu)<% end %> - <%= ut.user.email %><%= ut.staff_kind == "regia" ? "Regia" : "Trasmissione" %> - <% 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 %> -
- <% else %> -

Nessun membro staff. Usa i pulsanti sopra per il tuo account o <%= link_to "invita qualcuno", public_team_invite_path(@team) %>.

- <% end %> -
- -

Staff in attesa

-
- <% if @pending_invitations.any? %> - - - - <% @pending_invitations.each do |inv| %> - - - - - - - <% end %> - -
EmailRuoloScade
<%= inv.email %><%= inv.staff_kind == "regia" ? "Regia" : "Trasmissione" %><%= l inv.expires_at, format: :short %> - <%= button_to "Annulla", public_team_destroy_invitation_path(@team, inv), method: :delete, class: "btn btn-secondary", style: "padding:4px 10px;font-size:0.8rem" %> -
- <% else %> -

Nessuna richiesta in sospeso.

- <% end %> -
- - <% if @entitlements.can_access_recordings? && @recordings.any? %> -

Archivio gare

-
- - - - <% @recordings.each do |rec| %> - - - - - <% end %> - -
PartitaReplay
<%= rec.stream_session.match.team.name %> vs <%= rec.stream_session.match.opponent_name %><%= link_to "Guarda", rec.replay_url %>
-
- <% end %> - -

Usa l'app mobile con le stesse credenziali per avviare partite e dirette.

-
diff --git a/backend/app/views/public/teams/details.html.erb b/backend/app/views/public/teams/details.html.erb new file mode 100644 index 0000000..92a3a91 --- /dev/null +++ b/backend/app/views/public/teams/details.html.erb @@ -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) %> + +
+ + +
+
+

<%= @club.name %>

+

<%= @team.name %>

+

+ Piano: <%= @entitlements.plan.name %> (società) + · Responsabili trasmissione: <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> + · Partite attive: <%= @entitlements.concurrent_streams_used %><% if @entitlements.concurrent_streams_limit %> / <%= @entitlements.concurrent_streams_limit %><% else %> (illimitate)<% end %> +

+ <% if @team.description.present? %> +
<%= simple_format @team.description %>
+ <% elsif @can_manage %> +

+ Nessuna descrizione. + <%= link_to "Aggiungila dalla modifica squadra", public_edit_team_path(@team) %>. +

+ <% end %> +
+ <%= total_people %> in organico + <% TeamRosterMember::DISPLAY_ORDER.each do |cat| %> + <% count = @roster_by_category[cat].size %> + <% next if count.zero? %> + <%= count %> <%= TeamRosterMember::CATEGORY_LABELS[cat].downcase %> + <% end %> +
+
+
+ <% if @team.team_photo_url.present? %> + <%= image_tag @team.team_photo_url, alt: @team.name, class: "roster-hero__photo" %> + <% elsif @can_manage %> +
+ Foto squadra non ancora caricata + <%= 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" %> +
+ <% end %> +
+
+ +
+ <% 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" %> +
+ +
"> +
+ <% TeamRosterMember::DISPLAY_ORDER.each do |category| %> + <% members = @roster_by_category[category] %> +
+
+

<%= TeamRosterMember::CATEGORY_LABELS[category] %>

+ <%= members.size %> +
+ <% if members.any? %> +
+ <% members.each do |person| %> + <%= render "shared/roster_person_card", person: person, team: @team, editable: @can_manage %> + <% end %> +
+ <% else %> +

Nessuna persona in questa categoria.

+ <% end %> +
+ <% end %> +
+ + <% if @can_manage %> + + <% end %> +
+ + <%= 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 %> + + +
diff --git a/backend/app/views/public/teams/edit.html.erb b/backend/app/views/public/teams/edit.html.erb index 7670f3a..162f2a0 100644 --- a/backend/app/views/public/teams/edit.html.erb +++ b/backend/app/views/public/teams/edit.html.erb @@ -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…" %> + +
+ <%= label_tag "team[photo_file]", "Foto di squadra (gruppo o stemma)" %> + <% if @team.team_photo_url.present? %> +
+ <%= image_tag @team.team_photo_url, alt: "Foto squadra", class: "team-profile-photo-preview", width: 200 %> +
+ <% end %> + <%= file_field_tag "team[photo_file]", accept: "image/png,image/jpeg,image/webp", class: "branding-file-input" %> +
+ <%= render "shared/branding_fields", record: @team, show_inherit_hint: true, legend: "Branding squadra (override)" %>

Colori società: @@ -19,5 +33,5 @@ <%= submit_tag "Salva squadra", class: "btn btn-primary" %> <% end %>

-

<%= link_to "← Dashboard squadra", public_team_dashboard_path(@team) %>

+

<%= link_to "← Dettagli squadra", public_team_details_path(@team) %>

diff --git a/backend/app/views/public/teams/invite.html.erb b/backend/app/views/public/teams/invite.html.erb index 6ab4509..8b63c5e 100644 --- a/backend/app/views/public/teams/invite.html.erb +++ b/backend/app/views/public/teams/invite.html.erb @@ -1,43 +1,40 @@ -<% content_for :title, "Aggiungi staff — #{@team.name}" %> +<% content_for :title, "Staff trasmissione — #{@team.name}" %> <% content_for :robots, "noindex, nofollow" %>
-

Aggiungi staff

+

Responsabili trasmissione

- Trasmissione: <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> - · Regia: <%= @entitlements.staff_count_for("regia") %> / <%= @entitlements.max_staff_regia || "∞" %> + Assegnati: <%= @entitlements.staff_count_for("transmission") %> / <%= @entitlements.max_staff_transmission || "∞" %> + (per squadra, rinnovo annuale) +

+

+ Il responsabile trasmissione avvia la diretta dall’app (camera sul cavalletto). + Per il punteggio da un secondo telefono non serve un account: condivide il link regia dalla schermata di trasmissione + (WhatsApp, email, SMS, ecc.).

- <% 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? %>

- <%= current_user.email %> — aggiungi il tuo account senza invito (non potrai essere sia trasmissione sia regia). + <%= current_user.email %> — puoi gestire tu camera e punteggio, oppure invitare altri per la trasmissione.

-
- <%= 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" %> -
+ <%= button_to "Sono io — responsabile trasmissione", public_team_assign_self_staff_path(@team), method: :post, params: { staff_kind: "transmission" }, class: "btn btn-primary" %>
<% end %>

Invita un’altra email

<%= 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? %>

Condividi (valido 7 giorni):

<%= @invite_url %> -

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).

<% end %>
-

<%= link_to "← Dashboard", public_team_dashboard_path(@team) %>

+

<%= link_to "← Dettagli squadra", public_team_details_path(@team) %>

diff --git a/backend/app/views/shared/_billing_documents.html.erb b/backend/app/views/shared/_billing_documents.html.erb new file mode 100644 index 0000000..fa39905 --- /dev/null +++ b/backend/app/views/shared/_billing_documents.html.erb @@ -0,0 +1,94 @@ +<%# locals: (club:, payments:, invoices:) %> +
+

Pagamenti e fatture

+

+ Qui trovi lo storico dei pagamenti e le fatture emesse alla società. + I pagamenti premium sono elaborati in modo sicuro tramite Stripe. +

+ + <% unless club.billing_profile_complete? %> +
+ Dati di fatturazione incompleti. + 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" %> +
+ <% else %> +

+ Intestatario: <%= club.billing_profile_summary %> + · <%= link_to "Modifica dati", public_club_billing_profile_path(club) %> +

+ <% 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 %> + +

Pagamenti effettuati

+ <% if payments.any? %> + + + + + + + + + + + + <% payments.each do |payment| %> + + + + + + + + <% end %> + +
DataDescrizioneImportoStato
<%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %><%= payment.formatted_amount %><%= payment.status %> + <% if payment.receipt_url.present? %> + <%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %> + <% end %> +
+ <% else %> +

Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui (anche tramite «Sincronizza pagamenti»).

+ <% end %> + +

Fatture

+ <% if invoices.any? %> + + + + + + + + + + + + <% invoices.each do |invoice| %> + + + + + + + + <% end %> + +
NumeroDataImportoStato
<%= invoice.number %><%= l(invoice.issued_on) %><%= invoice.formatted_amount %><%= invoice.status %> + <% if invoice.downloadable? %> + <%= link_to "Scarica PDF", public_club_billing_invoice_path(club, invoice) %> + <% else %> + PDF in preparazione + <% end %> +
+ <% else %> +

Nessuna fattura disponibile. Compariranno qui dopo l’emissione.

+ <% end %> +
diff --git a/backend/app/views/shared/_billing_profile_fields.html.erb b/backend/app/views/shared/_billing_profile_fields.html.erb new file mode 100644 index 0000000..61cb47e --- /dev/null +++ b/backend/app/views/shared/_billing_profile_fields.html.erb @@ -0,0 +1,65 @@ +<%# locals: (record:, show_legend: true) %> +<% if show_legend %> +

Dati di fatturazione

+

+ Obbligatori per ricevere le fatture elettroniche (P.IVA/CF, indirizzo, SDI o PEC). + I pagamenti restano gestiti in modo sicuro da Stripe. +

+<% 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" %> + +
+
+ <%= label_tag "club[billing_vat_number]", "Partita IVA" %> + <%= text_field_tag "club[billing_vat_number]", record.billing_vat_number, placeholder: "12345678901" %> +
+
+ <%= label_tag "club[billing_fiscal_code]", "Codice Fiscale" %> + <%= text_field_tag "club[billing_fiscal_code]", record.billing_fiscal_code, placeholder: "RSSMRA80A01H501U" %> +
+
+ +<%= 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" %> + +
+
+ <%= label_tag "club[billing_city]", "Città" %> + <%= text_field_tag "club[billing_city]", record.billing_city %> +
+
+ <%= label_tag "club[billing_province]", "Prov." %> + <%= text_field_tag "club[billing_province]", record.billing_province, maxlength: 2, placeholder: "MI" %> +
+
+ <%= label_tag "club[billing_postal_code]", "CAP" %> + <%= text_field_tag "club[billing_postal_code]", record.billing_postal_code %> +
+
+ +<%= label_tag "club[billing_country]", "Paese (ISO)" %> +<%= text_field_tag "club[billing_country]", record.billing_country.presence || "IT", maxlength: 2 %> + +
+
+ <%= 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" %> +
+
+ <%= label_tag "club[billing_pec]", "PEC (alternativa a SDI)" %> + <%= email_field_tag "club[billing_pec]", record.billing_pec, placeholder: "pec@societa.it" %> +
+
diff --git a/backend/app/views/shared/_branding_fields.html.erb b/backend/app/views/shared/_branding_fields.html.erb index 0eb324f..558709f 100644 --- a/backend/app/views/shared/_branding_fields.html.erb +++ b/backend/app/views/shared/_branding_fields.html.erb @@ -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 %>
<%= local_assigns[:legend] || "Identità visiva" %> @@ -8,26 +12,59 @@

Lascia vuoto per usare logo e colori della società.

<% 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? %> -

- <%= image_tag record.effective_logo_url, alt: "", width: 64, height: 64, style: "border-radius:8px" %> -

- <% 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" %> -
- <%= color_field_tag "#{record.model_name.param_key}[primary_color]", record.primary_color.presence || record.effective_primary_color %> - +
+ <%= label_tag "branding[logo_file]", "Logo (immagine)" %> +
+ <%= 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? %> + > + Nessun logo — scegli un file per l’anteprima + +
+ <%= file_field_tag "branding[logo_file]", accept: "image/png,image/jpeg,image/webp", data: { branding_logo_file: true }, class: "branding-file-input" %>
- <%= label_tag "#{record.model_name.param_key}[secondary_color]", "Colore secondario" %> -
- <%= color_field_tag "#{record.model_name.param_key}[secondary_color]", record.secondary_color.presence || record.effective_secondary_color %> - + <%= 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" %> +
+ <%= 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 %> + +
+ <% %w[#e53935 #1565c0 #2e7d32 #ff9800 #212121 #ffffff].each do |hex| %> + + <% end %> +
+
+ + <%= label_tag "#{param_key}[secondary_color]", "Colore secondario" %> +
+ <%= 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 %> + +
+ <% %w[#ffffff #f5f5f5 #212121 #ffd54f #90caf9 #e53935].each do |hex| %> + + <% end %> +
+
+ +
+ Anteprima intestazione +
+ + <%= record.try(:name).presence || "Nome società / squadra" %> +
diff --git a/backend/app/views/shared/_club_subscription_status.html.erb b/backend/app/views/shared/_club_subscription_status.html.erb new file mode 100644 index 0000000..9258c1f --- /dev/null +++ b/backend/app/views/shared/_club_subscription_status.html.erb @@ -0,0 +1,19 @@ +<%# locals: (club:, entitlements: nil, subscription: nil) %> +<% current_plan = entitlements&.plan || subscription&.plan || Plan["free"] %> +

+ Società: <%= club.name %> + · Piano attuale: <%= current_plan.name %> + (valido per tutte le squadre) + · <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %> +

+<% if subscription&.stripe_subscription_id.present? %> +

+ Stato Stripe: <%= subscription.status %> + <% if subscription.current_period_end.present? %> + · Rinnovo: <%= l(subscription.current_period_end, format: :long) %> + <% end %> + <% if subscription.cancel_at_period_end? %> + · Disdetta alla scadenza + <% end %> +

+<% end %> diff --git a/backend/app/views/shared/_input_toggle.html.erb b/backend/app/views/shared/_input_toggle.html.erb new file mode 100644 index 0000000..972f97c --- /dev/null +++ b/backend/app/views/shared/_input_toggle.html.erb @@ -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" %> +
+ <%= label_tag field_id, label %> +
+ <%= 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 } + ) %> + +
+
diff --git a/backend/app/views/shared/_marketing_footer.html.erb b/backend/app/views/shared/_marketing_footer.html.erb index dddd7cf..a43b13a 100644 --- a/backend/app/views/shared/_marketing_footer.html.erb +++ b/backend/app/views/shared/_marketing_footer.html.erb @@ -10,5 +10,8 @@ <%= link_to "Privacy", public_privacy_path %> · <%= link_to "Termini", public_termini_path %>
+
diff --git a/backend/app/views/shared/_marketing_nav.html.erb b/backend/app/views/shared/_marketing_nav.html.erb index 3323610..b327a01 100644 --- a/backend/app/views/shared/_marketing_nav.html.erb +++ b/backend/app/views/shared/_marketing_nav.html.erb @@ -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" %> diff --git a/backend/app/views/shared/_plan_cards.html.erb b/backend/app/views/shared/_plan_cards.html.erb new file mode 100644 index 0000000..57be545 --- /dev/null +++ b/backend/app/views/shared/_plan_cards.html.erb @@ -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? %> + +
+ <% @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 %> +
"> +

<%= plan.name %>

+ <% if plan.slug == "free" %> +
€0
+ <% elsif plan.slug == "premium_light" %> +
€40/anno
+

oppure €5/mese

+ <% else %> +
€200/anno
+

oppure €20/mese

+ <% end %> +
    +
  • Responsabili trasmissione: <%= plan.max_staff_transmission || "illimitato" %> / anno per squadra
  • +
  • Partite: <%= plan.concurrent_streams_limit || "illimitate" %> in contemporanea (tutta la società)
  • +
  • Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %>
  • +
  • YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %>
  • +
+ <% if billing_mode %> + <% case action[:kind] %> + <% when :current %> + <%= action[:label] %> + <% when :contact, :disabled %> +

<%= action[:label] %>

+ <% 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 %> +
+ <% end %> +
+ +<% if show_stripe_portal && billing_mode && subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription&.stripe_customer_id.present? %> +

+ <%= button_to "Gestisci fatturazione su Stripe", public_club_portal_path(club), class: "btn btn-secondary" %> + Cambia metodo di pagamento, scarica fatture o disdici l'abbonamento. +

+<% end %> diff --git a/backend/app/views/shared/_roster_member_form.html.erb b/backend/app/views/shared/_roster_member_form.html.erb new file mode 100644 index 0000000..767e544 --- /dev/null +++ b/backend/app/views/shared/_roster_member_form.html.erb @@ -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 %> +

<%= member.persisted? ? "Modifica scheda" : "Aggiungi persona" %>

+

Compila i campi e salva per aggiornare l’organico.

+ + <%= 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" %> + +
+
> + <%= 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 %> +
+
> + <%= 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 %> +
+
+ +
> + <%= 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 %> +
+ + <%= 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? %> +
<%= roster_person_avatar(member, team, size: 72) %>
+ <% end %> + <%= file_field_tag "team_roster_member[photo_file]", accept: "image/png,image/jpeg,image/webp" %> +

Senza foto verrà mostrato un avatar con i colori della squadra.

+ + <%= submit_tag (member.persisted? ? "Salva scheda" : "Aggiungi all'organico"), class: "btn btn-primary" %> +<% end %> diff --git a/backend/app/views/shared/_roster_person_card.html.erb b/backend/app/views/shared/_roster_person_card.html.erb new file mode 100644 index 0000000..f82df12 --- /dev/null +++ b/backend/app/views/shared/_roster_person_card.html.erb @@ -0,0 +1,29 @@ +<% person = local_assigns[:person] %> +<% team = local_assigns[:team] %> +<% editable = local_assigns.fetch(:editable, false) %> +<% compact = local_assigns.fetch(:compact, false) %> + +
"> +
+ <%= roster_person_avatar(person, team, size: 72) %> +
+
+

+ <%= person.full_name %> + <% if person.category == "player" && person.jersey_number.present? %> + #<%= person.jersey_number %> + <% end %> +

+

<%= person.display_role %>

+ <% if person.bio.present? %> +

<%= person.bio %>

+ <% end %> +
+ <% if editable %> +
+ <%= 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?" } } %> +
+ <% end %> +
diff --git a/backend/app/views/shared/_stripe_secure_payment.html.erb b/backend/app/views/shared/_stripe_secure_payment.html.erb new file mode 100644 index 0000000..56ed2a1 --- /dev/null +++ b/backend/app/views/shared/_stripe_secure_payment.html.erb @@ -0,0 +1,12 @@ +<%# locals: (compact: false) %> +
"> + +

+ <% if local_assigns.fetch(:compact, false) %> + Pagamenti premium elaborati in modo sicuro tramite Stripe. + <% else %> + I pagamenti dei piani premium sono elaborati in modo sicuro tramite + Stripe (certificazione PCI DSS). Match Live TV non memorizza i numeri completi della carta. + <% end %> +

+
diff --git a/backend/config/environments/production.rb b/backend/config/environments/production.rb index 14771f6..79ec07b 100644 --- a/backend/config/environments/production.rb +++ b/backend/config/environments/production.rb @@ -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 diff --git a/backend/config/environments/test.rb b/backend/config/environments/test.rb index 0c616a1..ffa46f7 100644 --- a/backend/config/environments/test.rb +++ b/backend/config/environments/test.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index e2ee05a..8820d37 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -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 diff --git a/backend/db/migrate/20260527180000_add_team_profile_and_roster.rb b/backend/db/migrate/20260527180000_add_team_profile_and_roster.rb new file mode 100644 index 0000000..410343f --- /dev/null +++ b/backend/db/migrate/20260527180000_add_team_profile_and_roster.rb @@ -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 diff --git a/backend/db/migrate/20260528100000_add_club_billing_and_documents.rb b/backend/db/migrate/20260528100000_add_club_billing_and_documents.rb new file mode 100644 index 0000000..1d4dbf9 --- /dev/null +++ b/backend/db/migrate/20260528100000_add_club_billing_and_documents.rb @@ -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 diff --git a/backend/db/migrate/20260529120000_add_regia_token_to_stream_sessions.rb b/backend/db/migrate/20260529120000_add_regia_token_to_stream_sessions.rb new file mode 100644 index 0000000..223951d --- /dev/null +++ b/backend/db/migrate/20260529120000_add_regia_token_to_stream_sessions.rb @@ -0,0 +1,7 @@ +class AddRegiaTokenToStreamSessions < ActiveRecord::Migration[7.2] + def change + add_column :stream_sessions, :regia_token_digest, :string + add_column :stream_sessions, :regia_token_expires_at, :datetime + add_index :stream_sessions, :regia_token_digest, unique: true + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 067052e..4773087 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do +ActiveRecord::Schema[7.2].define(version: 2026_05_29_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -51,6 +51,45 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do t.index ["username"], name: "index_admin_accounts_on_username", unique: true end + create_table "billing_invoices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "club_id", null: false + t.uuid "billing_payment_id" + 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["billing_payment_id"], name: "index_billing_invoices_on_billing_payment_id" + t.index ["club_id", "issued_on"], name: "index_billing_invoices_on_club_id_and_issued_on" + t.index ["club_id", "number"], name: "index_billing_invoices_on_club_id_and_number", unique: true + t.index ["club_id"], name: "index_billing_invoices_on_club_id" + end + + create_table "billing_payments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "club_id", null: false + 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["club_id", "paid_at"], name: "index_billing_payments_on_club_id_and_paid_at" + t.index ["club_id"], name: "index_billing_payments_on_club_id" + t.index ["stripe_invoice_id"], name: "index_billing_payments_on_stripe_invoice_id", unique: true, where: "(stripe_invoice_id IS NOT NULL)" + end + create_table "club_memberships", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "user_id", null: false t.uuid "club_id", null: false @@ -70,6 +109,19 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do t.string "secondary_color", default: "#ffffff", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + 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 "device_states", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| @@ -175,8 +227,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do t.string "timeout_job_id" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "regia_token_digest" + t.datetime "regia_token_expires_at" t.index ["match_id"], name: "index_stream_sessions_on_match_id" t.index ["publish_token"], name: "index_stream_sessions_on_publish_token", unique: true + t.index ["regia_token_digest"], name: "index_stream_sessions_on_regia_token_digest", unique: true t.index ["status"], name: "index_stream_sessions_on_status" t.index ["user_id"], name: "index_stream_sessions_on_user_id" end @@ -213,6 +268,20 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do t.index ["token_digest"], name: "index_team_invitations_on_token_digest", unique: true end + create_table "team_roster_members", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "team_id", null: false + t.string "category", default: "player", null: false + t.string "full_name", null: false + t.string "role_label" + t.integer "jersey_number" + t.text "bio" + t.integer "position", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["team_id", "category", "position"], name: "index_team_roster_members_on_team_id_and_category_and_position" + t.index ["team_id"], name: "index_team_roster_members_on_team_id" + end + create_table "teams", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.string "name", null: false t.string "sport", default: "volleyball" @@ -222,6 +291,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do t.uuid "club_id", null: false t.string "primary_color" t.string "secondary_color" + t.text "description" t.index ["club_id"], name: "index_teams_on_club_id" end @@ -265,6 +335,9 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" + add_foreign_key "billing_invoices", "billing_payments" + add_foreign_key "billing_invoices", "clubs" + add_foreign_key "billing_payments", "clubs" add_foreign_key "club_memberships", "clubs" add_foreign_key "club_memberships", "users" add_foreign_key "device_states", "stream_sessions" @@ -278,6 +351,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_27_160101) do add_foreign_key "subscriptions", "clubs" add_foreign_key "subscriptions", "plans" add_foreign_key "team_invitations", "teams" + add_foreign_key "team_roster_members", "teams" add_foreign_key "teams", "clubs" add_foreign_key "user_teams", "teams" add_foreign_key "user_teams", "users" diff --git a/backend/public/admin.css b/backend/public/admin.css index a735ceb..33a599c 100644 --- a/backend/public/admin.css +++ b/backend/public/admin.css @@ -226,6 +226,33 @@ body.admin-body { .team-list a { color: var(--text); text-decoration: none; } .team-list a:hover { color: var(--red); } +.admin-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; +} + +.admin-btn { + display: inline-block; + padding: 0.45rem 0.9rem; + border: none; + border-radius: 6px; + font-size: 0.85rem; + font-weight: 600; + cursor: pointer; + text-decoration: none; +} + +.admin-btn--sm { padding: 0.25rem 0.55rem; font-size: 0.75rem; } + +.admin-btn--danger { + background: var(--red); + color: #fff; +} + +.admin-btn--danger:hover { filter: brightness(1.1); } + .muted { color: var(--muted); } .empty { color: var(--muted); font-size: 0.9rem; margin: 0; } diff --git a/backend/public/branding-form.js b/backend/public/branding-form.js new file mode 100644 index 0000000..914eb69 --- /dev/null +++ b/backend/public/branding-form.js @@ -0,0 +1,129 @@ +(function () { + function initBrandingFieldset(fieldset) { + initLogoPreview(fieldset); + fieldset.querySelectorAll("[data-branding-color-row]").forEach(initColorRow); + initLivePreview(fieldset); + initNamePreview(fieldset); + } + + function initLogoPreview(fieldset) { + var fileInput = fieldset.querySelector("[data-branding-logo-file]"); + var previewImg = fieldset.querySelector("[data-branding-logo-img]"); + var placeholder = fieldset.querySelector("[data-branding-logo-placeholder]"); + if (!fileInput || !previewImg) return; + + fileInput.addEventListener("change", function () { + var file = fileInput.files && fileInput.files[0]; + if (!file) return; + + var reader = new FileReader(); + reader.onload = function (e) { + previewImg.src = e.target.result; + previewImg.hidden = false; + if (placeholder) placeholder.hidden = true; + fieldset.dispatchEvent(new CustomEvent("branding:logochange", { bubbles: true })); + }; + reader.readAsDataURL(file); + }); + + var urlInput = fieldset.querySelector(".branding-url-input"); + if (urlInput) { + urlInput.addEventListener("change", function () { + var url = urlInput.value.trim(); + if (!url) return; + previewImg.src = url; + previewImg.hidden = false; + if (placeholder) placeholder.hidden = true; + fieldset.dispatchEvent(new CustomEvent("branding:logochange", { bubbles: true })); + }); + } + } + + function initColorRow(row) { + var picker = row.querySelector("[data-color-picker]"); + var hexInput = row.querySelector("[data-color-hex]"); + var swatch = row.querySelector("[data-color-swatch]"); + if (!picker || !hexInput) return; + + function applyColor(hex) { + var normalized = normalizeHex(hex); + if (!normalized) return; + picker.value = normalized; + hexInput.value = normalized; + if (swatch) swatch.style.background = normalized; + row.dispatchEvent(new CustomEvent("branding:colorchange", { bubbles: true })); + } + + picker.addEventListener("input", function () { + applyColor(picker.value); + }); + + hexInput.addEventListener("input", function () { + var v = hexInput.value.trim(); + if (/^#[0-9A-Fa-f]{6}$/.test(v)) applyColor(v); + else if (/^[0-9A-Fa-f]{6}$/.test(v)) applyColor("#" + v); + }); + + hexInput.addEventListener("blur", function () { + applyColor(hexInput.value || picker.value); + }); + + row.querySelectorAll("[data-color-preset]").forEach(function (btn) { + btn.addEventListener("click", function () { + applyColor(btn.getAttribute("data-color-preset")); + }); + }); + + applyColor(picker.value || hexInput.value); + } + + function initLivePreview(fieldset) { + var card = fieldset.querySelector("[data-branding-live-preview]"); + if (!card) return; + + function refresh() { + var primary = fieldset.querySelector('[data-branding-color-row="primary"] [data-color-picker]'); + var secondary = fieldset.querySelector('[data-branding-color-row="secondary"] [data-color-picker]'); + var logoImg = fieldset.querySelector("[data-branding-logo-img]"); + var header = card.querySelector("[data-preview-header]"); + var logoSlot = card.querySelector("[data-preview-logo]"); + + if (header && primary && secondary) { + header.style.setProperty("--preview-primary", primary.value); + header.style.setProperty("--preview-secondary", secondary.value); + } + if (logoSlot && logoImg && !logoImg.hidden && logoImg.src && logoImg.src.indexOf("data:image/gif") === -1) { + logoSlot.innerHTML = + ''; + } + } + + fieldset.addEventListener("branding:colorchange", refresh); + fieldset.addEventListener("branding:logochange", refresh); + refresh(); + } + + function initNamePreview(fieldset) { + var card = fieldset.querySelector("[data-branding-live-preview]"); + if (!card) return; + var nameEl = card.querySelector(".branding-live-preview__name"); + var form = fieldset.closest("form"); + if (!nameEl || !form) return; + + var nameInput = form.querySelector("[name='club[name]'], [name='team[name]']"); + if (!nameInput) return; + + nameInput.addEventListener("input", function () { + nameEl.textContent = nameInput.value.trim() || nameEl.textContent; + }); + } + + function normalizeHex(value) { + var v = (value || "").trim(); + if (/^[0-9A-Fa-f]{6}$/.test(v)) v = "#" + v; + if (!/^#[0-9A-Fa-f]{6}$/.test(v)) return null; + return v.toLowerCase(); + } + + document.querySelectorAll(".branding-fields").forEach(initBrandingFieldset); +})(); diff --git a/backend/public/live.css b/backend/public/live.css index 7723582..3ea06cd 100644 --- a/backend/public/live.css +++ b/backend/public/live.css @@ -131,11 +131,46 @@ transform: translateY(-2px); } -.live-main .live-card h3 { +.live-main .live-card h3, +.live-main .live-card .live-card__title { margin: 0 0 6px; font-size: 1.05rem; } +.live-main .live-card__club { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: #e53935; + letter-spacing: 0.02em; + margin-bottom: 4px; +} + +.live-main .live-card__matchup { + display: block; + font-size: 1.05rem; + font-weight: 700; + color: #fff; + line-height: 1.3; +} + +.live-main .live-page-heading { + margin: 0 0 12px; +} + +.live-main .live-page-heading__club { + margin: 0 0 6px; + font-size: 0.9rem; + font-weight: 600; + color: #e53935; +} + +.live-main .live-page-heading__matchup { + margin: 0; + font-size: 1.6rem; + line-height: 1.2; +} + .live-main .live-card .meta { color: #999; font-size: 0.85rem; diff --git a/backend/public/marketing.css b/backend/public/marketing.css index 5b2d64c..36e6348 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -260,10 +260,417 @@ body.nav-menu-open { overflow: hidden; } .branding-fields { border: 1px solid #252530; border-radius: 10px; padding: 16px 18px; margin: 16px 0; } .branding-fields legend { padding: 0 8px; color: #ccc; font-size: 0.9rem; } .branding-hint { color: #888; font-size: 0.88rem; margin: 0 0 12px; } -.branding-preview { margin: 8px 0 12px; } -.color-field { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; } +.branding-logo-block { margin-bottom: 14px; } +.branding-logo-preview { + display: flex; + align-items: center; + justify-content: center; + min-height: 112px; + margin: 8px 0 10px; + padding: 12px; + border-radius: 10px; + border: 1px dashed #3d3d4a; + background: #12121a; +} +.branding-logo-preview__img { + max-width: 96px; + max-height: 96px; + border-radius: 10px; + object-fit: contain; +} +.branding-logo-preview__placeholder { color: #777; font-size: 0.88rem; text-align: center; } +.branding-file-input { margin-bottom: 0 !important; } +.branding-url-input { margin-bottom: 14px; } +.branding-color-row { + display: grid; + grid-template-columns: 56px 1fr 36px; + grid-template-rows: auto auto; + gap: 8px 10px; + align-items: center; + margin-bottom: 6px; +} +.branding-color-picker { + width: 56px !important; + height: 44px !important; + padding: 2px !important; + margin: 0 !important; + border: 1px solid #3d3d4a !important; + border-radius: 8px !important; + cursor: pointer; + background: transparent; +} +.branding-hex-input { + margin: 0 !important; + font-family: ui-monospace, monospace; + letter-spacing: 0.04em; +} +.branding-color-swatch { + width: 36px; + height: 36px; + border-radius: 8px; + border: 2px solid #555; + box-shadow: inset 0 0 0 1px rgba(0,0,0,0.2); +} +.branding-color-presets { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} +.branding-preset { + width: 28px; + height: 28px; + padding: 0; + border: 2px solid #444; + border-radius: 6px; + cursor: pointer; + transition: transform 0.12s, border-color 0.12s; +} +.branding-preset:hover { + transform: scale(1.08); + border-color: #fff; +} +.branding-live-preview { + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid #2a2a36; +} +.branding-live-preview__label { + display: block; + font-size: 0.78rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.06em; + margin-bottom: 8px; +} +.branding-live-preview__header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border-radius: 10px; + background: linear-gradient(135deg, var(--preview-primary, #e53935) 0%, color-mix(in srgb, var(--preview-primary, #e53935) 70%, #000) 100%); + border: 2px solid var(--preview-secondary, #fff); +} +.branding-live-preview__name { + font-weight: 800; + font-size: 1.05rem; + color: var(--preview-secondary, #fff); + text-shadow: 0 1px 2px rgba(0,0,0,0.35); +} +.branding-live-preview__logo img { + border-radius: 8px; + object-fit: contain; +} .color-swatch { display: inline-block; width: 28px; height: 28px; border-radius: 6px; border: 1px solid #444; vertical-align: middle; } .team-swatch { display: inline-block; width: 12px; height: 12px; border-radius: 3px; margin-right: 6px; vertical-align: middle; } + +/* Team profile & organico */ +.team-roster-page { padding-top: 20px; } +.team-profile-header { + display: grid; + grid-template-columns: minmax(120px, 220px) 1fr; + gap: 20px; + align-items: start; + margin-bottom: 28px; +} +.team-profile-header__photo, +.team-profile-photo-preview { + width: 100%; + border-radius: 12px; + object-fit: cover; + border: 2px solid var(--card-border, #2a2a36); +} +.team-profile-header__desc { color: #bbb; line-height: 1.55; margin: 8px 0 0; } + +.roster-hero { + display: flex; + flex-direction: column; + gap: 0; + margin-bottom: 24px; + padding: 0; + overflow: hidden; +} +.roster-hero__intro { + padding: 22px 22px 18px; +} +.roster-hero__media { + width: 100%; + line-height: 0; + background: #0a0a0e; + border-top: 1px solid #2a2a36; +} +.roster-hero__photo { + display: block; + width: 100%; + height: auto; + max-height: none; + object-fit: contain; + object-position: center; + border: none; + border-radius: 0; +} +.roster-hero__photo--empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 8px; + width: 100%; + min-height: 180px; + padding: 28px 20px; + background: #12121a; + border-top: 1px solid #2a2a36; + border-radius: 0; + color: #777; + font-size: 0.9rem; + line-height: 1.4; +} +.roster-hero__club { + margin: 0 0 4px; + font-size: 0.82rem; + color: #888; + text-transform: uppercase; + letter-spacing: 0.06em; +} +.roster-hero__title { margin: 0 0 10px; font-size: 1.75rem; color: #fff; } +.roster-hero__desc { color: #bbb; line-height: 1.55; margin: 0; font-size: 0.95rem; } +.roster-hero__desc--muted { color: #888; } +.roster-hero__desc p { margin: 0 0 8px; } +.team-details-meta { + margin: 0 0 10px; + font-size: 0.88rem; + color: #999; + line-height: 1.5; +} +.team-details-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-bottom: 22px; +} +.team-details-footer { + margin-top: 28px; + color: #888; + font-size: 0.9rem; +} +.team-streaming-staff { + margin-top: 36px; + padding-top: 28px; + border-top: 1px solid #2a2a36; +} +.team-streaming-staff h2 { margin: 24px 0 12px; font-size: 1.1rem; } +.team-streaming-staff__self { margin-bottom: 20px; } +.team-streaming-staff__self h2 { margin-top: 0; font-size: 1.05rem; } +.roster-layout--readonly { grid-template-columns: 1fr; } +.roster-stats { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.roster-stat { + font-size: 0.8rem; + color: #aaa; + background: #12121a; + border: 1px solid #2a2a36; + padding: 5px 10px; + border-radius: 999px; +} +.roster-stat strong { color: #fff; } +.team-dashboard-nav { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin: 0 0 12px; + font-size: 0.92rem; +} +.team-dashboard-nav__club { + color: #fff; + font-weight: 600; + text-decoration: none; +} +.team-dashboard-nav__club:hover { color: #e53935; text-decoration: none; } +.team-dashboard-nav__sep { color: #555; } +.team-dashboard-nav a:not(.team-dashboard-nav__club) { color: #aaa; } +.team-dashboard-nav a:not(.team-dashboard-nav__club):hover { color: #fff; } +.team-dashboard-desc { color: #bbb; max-width: 640px; line-height: 1.55; } +.team-dashboard-photo { margin: 12px 0 16px; } +.team-dashboard-photo img { max-height: 200px; } + +.roster-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) 320px; + gap: 20px; + align-items: start; +} +.roster-main { display: flex; flex-direction: column; gap: 16px; min-width: 0; } +.roster-section { padding: 0; overflow: hidden; } +.roster-section__head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid #2a2a36; + background: #14141c; +} +.roster-section__title { + margin: 0; + font-size: 1rem; + font-weight: 700; + color: #fff; +} +.roster-section__count { + font-size: 0.78rem; + font-weight: 700; + color: #aaa; + background: #1c1c26; + border: 1px solid #333; + padding: 3px 10px; + border-radius: 999px; +} +.roster-section__empty { + margin: 0; + padding: 20px 18px; + color: #777; + font-size: 0.9rem; + font-style: italic; +} +.roster-list { + display: flex; + flex-direction: column; + gap: 0; +} +.roster-list .roster-card { + border-radius: 0; + border: none; + border-bottom: 1px solid #252530; + background: transparent; +} +.roster-list .roster-card:last-child { border-bottom: none; } + +.roster-sidebar__form { padding: 20px; } +.roster-sidebar__title { margin: 0 0 6px; font-size: 1.1rem; color: #fff; } +.roster-sidebar__lead { margin: 0 0 16px; font-size: 0.85rem; color: #888; line-height: 1.4; } +.roster-sidebar .roster-member-form { position: sticky; top: 16px; } + +.roster-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 14px; +} +.roster-grid--compact { grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); } + +.roster-card { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 14px 16px; + align-items: center; + padding: 14px 18px; +} +.roster-card__avatar { flex-shrink: 0; } +.roster-card__body { min-width: 0; text-align: left; } +.roster-card__name { + margin: 0 0 4px; + font-size: 1rem; + color: #fff; + display: flex; + align-items: baseline; + gap: 8px; + flex-wrap: wrap; +} +.roster-card__number { + font-size: 0.88rem; + color: var(--avatar-primary, #e53935); + font-weight: 800; +} +.roster-card__role { margin: 0; font-size: 0.82rem; color: #e53935; font-weight: 600; } +.roster-card__bio { + margin: 6px 0 0; + font-size: 0.82rem; + color: #999; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.roster-card__actions { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} +.roster-card__btn { + display: inline-block; + padding: 6px 12px; + font-size: 0.78rem; + font-weight: 600; + border-radius: 6px; + border: 1px solid #3d3d4a; + background: #252530; + color: #eee; + cursor: pointer; + text-decoration: none; + text-align: center; +} +.roster-card__btn:hover { background: #333; color: #fff; text-decoration: none; } +.roster-card__btn--danger { border-color: #5c2020; color: #ffb4b4; } +.roster-card__btn--danger:hover { background: #3d1b1b; } + +.roster-card--compact { + grid-template-columns: 1fr; + text-align: center; + padding: 16px; + border: 1px solid #2a2a36; + border-radius: 12px; + background: #16161e; +} +.roster-card--compact .roster-card__body { text-align: center; } +.roster-card--compact .roster-card__name { justify-content: center; } +.roster-card--compact .roster-card__avatar { display: flex; justify-content: center; } + +@media (max-width: 960px) { + .roster-layout { grid-template-columns: 1fr; } + .roster-sidebar .roster-member-form { position: static; } + .roster-sidebar { order: -1; } +} +@media (max-width: 640px) { + .roster-card { + grid-template-columns: auto 1fr; + grid-template-rows: auto auto; + } + .roster-card__actions { + grid-column: 1 / -1; + flex-direction: row; + justify-content: flex-start; + } +} + +.roster-avatar { + border-radius: 50%; + object-fit: cover; + flex-shrink: 0; + border: 3px solid var(--avatar-secondary, #fff); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.35); +} +.roster-avatar--photo { display: block; } +.roster-avatar--placeholder { + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + font-size: calc(var(--width, 96px) * 0.32); + color: var(--avatar-secondary, #fff); + background: linear-gradient(145deg, var(--avatar-primary, #e53935), color-mix(in srgb, var(--avatar-primary, #e53935) 55%, #000)); + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); + letter-spacing: 0.04em; +} +.roster-form-photo-preview { margin: 8px 0 12px; } + .club-header { display: flex; align-items: center; gap: 16px; margin-bottom: 8px; } .club-header-logo { border-radius: 10px; object-fit: contain; } .club-meta { color: #aaa; margin: 4px 0 0; font-size: 0.92rem; } @@ -509,11 +916,44 @@ body.nav-menu-open { overflow: hidden; } .features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 18px; } .feature-card { background: #14141c; border-radius: 12px; padding: 20px; border: 1px solid #2a2a36; } .feature-card h3 { margin: 0 0 8px; font-size: 1.05rem; } +.stripe-secure { + display: flex; + align-items: flex-start; + gap: 12px; + margin: 20px 0 0; + padding: 14px 16px; + background: #14141c; + border: 1px solid #2a2a36; + border-radius: 10px; + color: #aaa; + font-size: 0.9rem; + line-height: 1.5; +} +.stripe-secure i { + color: #635bff; + font-size: 1.1rem; + margin-top: 2px; + flex-shrink: 0; +} +.stripe-secure p { margin: 0; } +.stripe-secure strong { color: #ddd; } +.stripe-secure--compact { + margin: 0; + padding: 0; + background: transparent; + border: none; + font-size: 0.85rem; +} +.stripe-secure--compact i { font-size: 0.95rem; color: #888; } .site-footer { border-top: 1px solid #252530; padding: 32px 0; margin-top: 40px; color: #888; font-size: 0.88rem; } .site-footer .wrap { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px; } +.site-footer__stripe { flex: 1 1 100%; margin-top: 4px; } .compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } .compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } .compare-table th { color: #aaa; font-weight: 600; } +.billing-documents h2 { margin-top: 0; } +.billing-table { font-size: 0.9rem; } +.billing-table a { white-space: nowrap; } .card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; } .seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; } @@ -637,3 +1077,45 @@ label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; f } .auth-page .card-wide { max-width: 480px; } .auth-page .auth-footer { margin-top: 20px; font-size: 0.92rem; color: #aaa; text-align: center; } + +.input-toggle { margin-bottom: 14px; } +.input-toggle__field { position: relative; } +.input-toggle__input { + width: 100%; + padding: 10px 44px 10px 12px; + margin-bottom: 0; + border-radius: 8px; + border: 1px solid #333; + background: #0f0f12; + color: #fff; + font-size: 1rem; +} +.input-toggle__input:focus { + outline: none; + border-color: #e53935; + box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25); +} +.input-toggle__btn { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + padding: 0; + border: none; + border-radius: 6px; + background: transparent; + color: #9a9aaa; + cursor: pointer; +} +.input-toggle__btn:hover, +.input-toggle__btn:focus-visible { + color: #e53935; + outline: none; +} +.input-toggle__btn i { font-size: 1.05rem; pointer-events: none; } +.auth-page .auth-forgot { margin: -4px 0 16px; font-size: 0.88rem; } diff --git a/backend/public/password-toggle.js b/backend/public/password-toggle.js new file mode 100644 index 0000000..dbc6614 --- /dev/null +++ b/backend/public/password-toggle.js @@ -0,0 +1,37 @@ +(function () { + function initToggle(wrapper) { + var input = wrapper.querySelector(".input-toggle__input"); + var btn = wrapper.querySelector(".input-toggle__btn"); + if (!input || !btn || btn.dataset.bound === "1") return; + + btn.dataset.bound = "1"; + var icon = btn.querySelector("i"); + var hiddenLabel = btn.getAttribute("aria-label") || "Mostra"; + var visibleLabel = btn.getAttribute("data-label-hide") || "Nascondi"; + + function setVisible(visible) { + input.type = visible ? input.dataset.visibleType || "text" : "password"; + btn.setAttribute("aria-label", visible ? visibleLabel : hiddenLabel); + btn.setAttribute("aria-pressed", visible ? "true" : "false"); + if (icon) { + icon.classList.toggle("fa-eye", !visible); + icon.classList.toggle("fa-eye-slash", visible); + } + } + + setVisible(false); + btn.addEventListener("click", function () { + setVisible(input.type === "password"); + }); + } + + function initAll() { + document.querySelectorAll("[data-input-toggle]").forEach(initToggle); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initAll); + } else { + initAll(); + } +})(); diff --git a/backend/public/regia.css b/backend/public/regia.css new file mode 100644 index 0000000..ef23b11 --- /dev/null +++ b/backend/public/regia.css @@ -0,0 +1,217 @@ +/* Pagina regia mobile — controllo punteggio da browser */ +:root { + --regia-bg: #0a0a0e; + --regia-surface: #14141c; + --regia-red: #e53935; + --regia-yellow: #f9a825; + --regia-text: #f5f5f5; + --regia-muted: #9ca3af; + --regia-safe-bottom: env(safe-area-inset-bottom, 0px); +} + +* { box-sizing: border-box; } + +.regia-body { + margin: 0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + background: var(--regia-bg); + color: var(--regia-text); + min-height: 100dvh; +} + +.regia-page { + max-width: 480px; + margin: 0 auto; + padding: 12px 16px calc(24px + var(--regia-safe-bottom)); +} + +.regia-header h1 { + margin: 0; + font-size: 1.1rem; + font-weight: 700; +} + +.regia-header p { + margin: 4px 0 0; + font-size: 0.85rem; + color: var(--regia-muted); +} + +.regia-badge { + display: inline-block; + margin-top: 8px; + padding: 4px 10px; + border-radius: 999px; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; +} + +.regia-badge--live { background: #2e7d32; color: #fff; } +.regia-badge--wait { background: #444; color: #ddd; } +.regia-badge--ended { background: #374151; color: #ddd; } + +.regia-preview { + margin: 14px 0; + border-radius: 12px; + overflow: hidden; + background: #000; + aspect-ratio: 16 / 9; + position: relative; +} + +.regia-preview video { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.regia-preview__placeholder { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: var(--regia-muted); + font-size: 0.88rem; + text-align: center; + padding: 16px; +} + +.regia-scoreboard { + background: var(--regia-surface); + border-radius: 12px; + padding: 16px; + margin-bottom: 12px; +} + +.regia-sets { + text-align: center; + font-size: 0.82rem; + color: var(--regia-muted); + margin: 0 0 8px; +} + +.regia-score-line { + text-align: center; + font-size: 1.5rem; + font-weight: 800; + margin: 0; +} + +.regia-partials { + text-align: center; + font-size: 0.78rem; + color: var(--regia-muted); + margin: 8px 0 0; +} + +.regia-team-row { + display: grid; + grid-template-columns: 1fr auto 1fr; + gap: 8px; + align-items: center; + margin-top: 16px; +} + +.regia-team-name { + font-size: 0.8rem; + font-weight: 600; + text-align: center; + line-height: 1.2; +} + +.regia-points { + font-size: 2rem; + font-weight: 800; + text-align: center; +} + +.regia-btn-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + margin-top: 10px; +} + +.regia-btn { + border: none; + border-radius: 10px; + padding: 16px 12px; + font-size: 1rem; + font-weight: 700; + cursor: pointer; + touch-action: manipulation; + -webkit-tap-highlight-color: transparent; +} + +.regia-btn:active { transform: scale(0.98); } +.regia-btn--point { background: var(--regia-red); color: #fff; } +.regia-btn--minus { background: #2a2a36; color: #fff; font-size: 0.9rem; padding: 12px; } +.regia-btn--yellow { background: var(--regia-yellow); color: #111; width: 100%; margin-top: 10px; } +.regia-btn--outline { + background: transparent; + border: 1px solid #444; + color: var(--regia-text); + width: 100%; + margin-top: 8px; +} +.regia-btn--danger { + background: transparent; + border: 1px solid var(--regia-red); + color: var(--regia-red); + width: 100%; + margin-top: 8px; +} + +.regia-share { + display: flex; + gap: 8px; + margin: 12px 0; +} + +.regia-share .regia-btn { flex: 1; font-size: 0.9rem; padding: 12px; } + +.regia-toast { + position: fixed; + bottom: calc(16px + var(--regia-safe-bottom)); + left: 16px; + right: 16px; + max-width: 448px; + margin: 0 auto; + padding: 12px 16px; + background: #1f2937; + border-radius: 10px; + font-size: 0.88rem; + display: none; + z-index: 100; +} + +.regia-toast.visible { display: block; } + +.regia-modal { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.75); + display: none; + align-items: center; + justify-content: center; + padding: 20px; + z-index: 200; +} + +.regia-modal.open { display: flex; } + +.regia-modal__card { + background: var(--regia-surface); + border-radius: 14px; + padding: 20px; + max-width: 340px; + width: 100%; + text-align: center; +} + +.regia-modal__card h2 { margin: 0 0 8px; font-size: 1.1rem; } +.regia-modal__card p { margin: 0 0 16px; color: var(--regia-muted); font-size: 0.9rem; } +.regia-modal__actions { display: flex; flex-direction: column; gap: 8px; } diff --git a/backend/public/regia.js b/backend/public/regia.js new file mode 100644 index 0000000..eb4616e --- /dev/null +++ b/backend/public/regia.js @@ -0,0 +1,252 @@ +(function () { + const root = document.getElementById("regia-app"); + if (!root) return; + + const cfg = { + token: root.dataset.token, + statusUrl: root.dataset.statusUrl, + scoreUrl: root.dataset.scoreUrl, + pauseUrl: root.dataset.pauseUrl, + stopUrl: root.dataset.stopUrl, + cableUrl: root.dataset.cableUrl, + hlsUrl: root.dataset.hlsUrl, + homeName: root.dataset.homeName, + awayName: root.dataset.awayName, + shareUrl: root.dataset.shareUrl, + shareTitle: root.dataset.shareTitle, + streamClosed: root.dataset.streamClosed === "true" + }; + + const els = { + badge: document.getElementById("regia-badge"), + setsLine: document.getElementById("sets-line"), + scoreLine: document.getElementById("score-line"), + partialsLine: document.getElementById("partials-line"), + homePoints: document.getElementById("home-points"), + toast: document.getElementById("regia-toast"), + modal: document.getElementById("regia-modal"), + modalTitle: document.getElementById("modal-title"), + modalBody: document.getElementById("modal-body"), + modalConfirm: document.getElementById("modal-confirm"), + modalCancel: document.getElementById("modal-cancel"), + preview: document.getElementById("regia-preview"), + previewPlaceholder: document.getElementById("regia-preview-placeholder") + }; + + let pendingAction = null; + let hls = null; + + function toast(msg) { + els.toast.textContent = msg; + els.toast.classList.add("visible"); + setTimeout(() => els.toast.classList.remove("visible"), 2800); + } + + function formatPartials(partials) { + if (!partials || !partials.length) return ""; + return partials + .map((p) => `Set ${p.set || p["set"]} ${p.home || p["home"]}-${p.away || p["away"]}`) + .join(" · "); + } + + function applyScorePayload(data) { + const s = data.score; + if (!s) return; + + const homePts = s.home_points ?? s.homePoints; + const awayPts = s.away_points ?? s.awayPoints; + const homeSets = s.home_sets ?? s.homeSets; + const awaySets = s.away_sets ?? s.awaySets; + const currentSet = s.current_set ?? s.currentSet; + + const awayEl = document.getElementById("away-points"); + els.homePoints.textContent = homePts; + if (awayEl) awayEl.textContent = awayPts; + els.setsLine.textContent = `Set ${currentSet} · Set vinti ${homeSets}-${awaySets}`; + els.scoreLine.textContent = `${cfg.homeName} ${homePts} - ${awayPts} ${cfg.awayName}`; + + const partialsText = formatPartials(s.set_partials || s.setPartials); + if (partialsText) { + els.partialsLine.textContent = `Parziali: ${partialsText}`; + els.partialsLine.hidden = false; + } + } + + function setBadge(data) { + if (data.stream_closed) { + els.badge.textContent = "Terminata"; + els.badge.className = "regia-badge regia-badge--ended"; + return; + } + if (data.on_air) { + els.badge.textContent = "In onda"; + els.badge.className = "regia-badge regia-badge--live"; + } else { + els.badge.textContent = "In attesa"; + els.badge.className = "regia-badge regia-badge--wait"; + } + } + + async function postScore(action) { + const res = await fetch(cfg.scoreUrl, { + method: "PATCH", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ cmd: action }) + }); + if (!res.ok) throw new Error("Errore aggiornamento punteggio"); + return res.json(); + } + + async function handleAction(action) { + try { + const data = await postScore(action); + applyScorePayload(data); + setBadge(data); + + if (data.set_won && action !== "close_set") { + const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; + openModal("Set vinto", `${winner} ha vinto il set. Chiudere il set?`, () => postScore("close_set")); + } else if (data.match_won) { + const winner = data.winner === "home" ? cfg.homeName : cfg.awayName; + openModal("Partita vinta", `${winner} ha vinto la partita. Chiudere la diretta?`, () => stopStream()); + } + } catch (e) { + toast(e.message || "Errore"); + } + } + + function openModal(title, body, onConfirm) { + els.modalTitle.textContent = title; + els.modalBody.textContent = body; + els.modal.classList.add("open"); + pendingAction = onConfirm; + } + + function closeModal() { + els.modal.classList.remove("open"); + pendingAction = null; + } + + els.modalConfirm.addEventListener("click", async () => { + const fn = pendingAction; + closeModal(); + if (fn) { + try { + const data = await fn(); + if (data) { + applyScorePayload(data); + setBadge(data); + } + } catch (e) { + toast(e.message || "Errore"); + } + } + }); + + els.modalCancel.addEventListener("click", closeModal); + + root.querySelectorAll("[data-action]").forEach((btn) => { + btn.addEventListener("click", () => { + const action = btn.dataset.action; + if (action === "close_set") { + openModal("Chiudi set", "Confermi la chiusura del set corrente?", () => postScore("close_set")); + } else { + handleAction(action); + } + }); + }); + + async function pollStatus() { + try { + const res = await fetch(cfg.statusUrl, { headers: { Accept: "application/json" } }); + if (!res.ok) return; + const data = await res.json(); + applyScorePayload(data); + setBadge(data); + if (data.stream_closed) { + cfg.streamClosed = true; + stopPreview(); + } + } catch (_) {} + } + + function initPreview() { + if (cfg.streamClosed || !els.preview || !cfg.hlsUrl) return; + + if (els.preview.canPlayType("application/vnd.apple.mpegurl")) { + els.preview.src = cfg.hlsUrl; + } else if (window.Hls && Hls.isSupported()) { + hls = new Hls({ maxBufferLength: 8 }); + hls.loadSource(cfg.hlsUrl); + hls.attachMedia(els.preview); + } + els.preview.addEventListener("playing", () => { + if (els.previewPlaceholder) els.previewPlaceholder.hidden = true; + }); + } + + function stopPreview() { + if (hls) { + hls.destroy(); + hls = null; + } + if (els.preview) els.preview.remove(); + if (els.previewPlaceholder) { + els.previewPlaceholder.hidden = false; + els.previewPlaceholder.textContent = "Diretta terminata"; + } + } + + async function pauseStream() { + await fetch(cfg.pauseUrl, { method: "POST", headers: { Accept: "application/json" } }); + toast("Trasmissione in pausa"); + } + + async function stopStream() { + const ok = confirm("Chiudere definitivamente la diretta?"); + if (!ok) return null; + const res = await fetch(cfg.stopUrl, { method: "POST", headers: { Accept: "application/json" } }); + if (!res.ok) throw new Error("Errore chiusura"); + const data = await res.json(); + cfg.streamClosed = true; + stopPreview(); + toast("Diretta chiusa"); + return data; + } + + document.getElementById("btn-pause")?.addEventListener("click", () => pauseStream().catch((e) => toast(e.message))); + document.getElementById("btn-stop")?.addEventListener("click", () => stopStream().catch((e) => toast(e.message))); + + async function shareLink() { + const payload = { + title: cfg.shareTitle, + text: "Apri questo link per gestire il punteggio della diretta:", + url: cfg.shareUrl + }; + if (navigator.share) { + try { + await navigator.share(payload); + return; + } catch (err) { + if (err.name === "AbortError") return; + } + } + await copyLink(); + } + + async function copyLink() { + try { + await navigator.clipboard.writeText(cfg.shareUrl); + toast("Link copiato"); + } catch (_) { + prompt("Copia il link:", cfg.shareUrl); + } + } + + document.getElementById("btn-share")?.addEventListener("click", () => shareLink()); + document.getElementById("btn-copy")?.addEventListener("click", () => copyLink()); + + initPreview(); + setInterval(pollStatus, 4000); + pollStatus(); +})(); diff --git a/backend/public/robots.txt b/backend/public/robots.txt index 8a2271d..2744ec1 100644 --- a/backend/public/robots.txt +++ b/backend/public/robots.txt @@ -1,4 +1,4 @@ -# Match Live TV — https://matchlivetv.eminux.it +# Match Live TV — https://www.matchlivetv.it User-agent: * Allow: / Allow: /live @@ -21,4 +21,4 @@ Disallow: /cable Disallow: /hls/ Disallow: /replay/ -Sitemap: https://matchlivetv.eminux.it/sitemap.xml +Sitemap: https://www.matchlivetv.it/sitemap.xml diff --git a/backend/public/roster-form.js b/backend/public/roster-form.js new file mode 100644 index 0000000..cafa428 --- /dev/null +++ b/backend/public/roster-form.js @@ -0,0 +1,38 @@ +(function () { + function syncRoleFields(form) { + var category = form.querySelector("[data-roster-category]"); + if (!category) return; + + var isPlayer = category.value === "player"; + var playerBlock = form.querySelector("[data-roster-role-player]"); + var otherBlock = form.querySelector("[data-roster-role-other]"); + var jerseyRow = form.querySelector("[data-roster-jersey-row]"); + var playerSelect = form.querySelector("[data-roster-role-player]"); + var otherInput = form.querySelector("[data-roster-role-other]"); + var jerseyInput = form.querySelector("[data-roster-jersey]"); + + if (playerBlock) playerBlock.hidden = !isPlayer; + if (otherBlock) otherBlock.hidden = isPlayer; + if (jerseyRow) jerseyRow.hidden = !isPlayer; + + if (playerSelect) { + playerSelect.disabled = !isPlayer; + playerSelect.name = isPlayer ? "team_roster_member[role_label]" : ""; + } + if (otherInput) { + otherInput.disabled = isPlayer; + otherInput.name = isPlayer ? "" : "team_roster_member[role_label]"; + } + if (jerseyInput) jerseyInput.disabled = !isPlayer; + } + + document.querySelectorAll("[data-roster-form]").forEach(function (form) { + var category = form.querySelector("[data-roster-category]"); + if (!category) return; + + syncRoleFields(form); + category.addEventListener("change", function () { + syncRoleFields(form); + }); + }); +})(); diff --git a/backend/spec/helpers/public/billing_helper_spec.rb b/backend/spec/helpers/public/billing_helper_spec.rb new file mode 100644 index 0000000..2348a71 --- /dev/null +++ b/backend/spec/helpers/public/billing_helper_spec.rb @@ -0,0 +1,38 @@ +require "rails_helper" + +RSpec.describe Public::BillingHelper, type: :helper do + before { load Rails.root.join("db/seeds/plans.rb") } + + let(:premium_light) { Plan["premium_light"] } + let(:premium_full) { Plan["premium_full"] } + let(:free_plan) { Plan["free"] } + + it "segna il piano corrente" do + action = helper.plan_billing_action( + current_slug: "premium_light", + target_plan: premium_light, + stripe_subscription_active: true + ) + expect(action[:kind]).to eq(:current) + end + + it "propone attivazione da free" do + action = helper.plan_billing_action( + current_slug: "free", + target_plan: premium_light, + stripe_subscription_active: false + ) + expect(action[:kind]).to eq(:checkout) + expect(action[:label]).to include("Attiva") + end + + it "propone upgrade con abbonamento attivo" do + action = helper.plan_billing_action( + current_slug: "premium_light", + target_plan: premium_full, + stripe_subscription_active: true + ) + expect(action[:kind]).to eq(:change) + expect(action[:label]).to include("Passa a") + end +end diff --git a/backend/spec/models/billing/invoice_spec.rb b/backend/spec/models/billing/invoice_spec.rb new file mode 100644 index 0000000..d573984 --- /dev/null +++ b/backend/spec/models/billing/invoice_spec.rb @@ -0,0 +1,27 @@ +require "rails_helper" + +RSpec.describe Billing::Invoice do + let(:club) { Club.create!(name: "Invoice Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + + it "richiede PDF per fatture emesse" do + invoice = club.billing_invoices.build( + number: "2026/001", + issued_on: Date.current, + amount_cents: 4000, + status: "issued" + ) + expect(invoice).not_to be_valid + expect(invoice.errors[:pdf]).to be_present + end + + it "accetta bozza senza PDF" do + invoice = club.billing_invoices.create!( + number: "2026/002", + issued_on: Date.current, + amount_cents: 4000, + status: "draft" + ) + expect(invoice).to be_persisted + expect(invoice.downloadable?).to be false + end +end diff --git a/backend/spec/models/club_billing_profile_spec.rb b/backend/spec/models/club_billing_profile_spec.rb new file mode 100644 index 0000000..d14cb23 --- /dev/null +++ b/backend/spec/models/club_billing_profile_spec.rb @@ -0,0 +1,29 @@ +require "rails_helper" + +RSpec.describe ClubBillingProfile do + let(:club) do + Club.create!( + name: "Test Club", + sport: "volleyball", + primary_color: "#e53935", + secondary_color: "#ffffff", + billing_legal_name: "ASD Test", + billing_email: "billing@test.it", + billing_address_line: "Via Roma 1", + billing_city: "Milano", + billing_postal_code: "20100", + billing_vat_number: "12345678901", + billing_recipient_code: "ABCDEFG" + ) + end + + it "is complete with required fields" do + expect(club.billing_profile_complete?).to be true + end + + it "requires SDI or PEC" do + club.billing_recipient_code = nil + club.billing_pec = nil + expect(club.billing_profile_complete?).to be false + end +end diff --git a/backend/spec/rails_helper.rb b/backend/spec/rails_helper.rb index e90fdb0..06d6fc8 100644 --- a/backend/spec/rails_helper.rb +++ b/backend/spec/rails_helper.rb @@ -1,4 +1,4 @@ -ENV["RAILS_ENV"] ||= "test" +ENV["RAILS_ENV"] = "test" require_relative "../config/environment" require "rspec/rails" @@ -6,4 +6,8 @@ RSpec.configure do |config| config.use_transactional_fixtures = true config.infer_spec_type_from_file_location! config.filter_rails_from_backtrace! + + config.before(:each, type: :request) do + host! "www.example.com" + end end diff --git a/backend/spec/requests/public/club_billing_spec.rb b/backend/spec/requests/public/club_billing_spec.rb new file mode 100644 index 0000000..e6fb08f --- /dev/null +++ b/backend/spec/requests/public/club_billing_spec.rb @@ -0,0 +1,62 @@ +require "rails_helper" + +RSpec.describe "Public club billing", type: :request do + let!(:coach) do + User.create!(email: "billing-coach@test.it", name: "Coach", password: "password123", role: "coach") + end + let!(:club) do + c = Club.create!(name: "Billing HTTP Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff", + billing_legal_name: "ASD Billing", billing_email: "bill@test.it", billing_address_line: "Via 1", + billing_city: "Milano", billing_postal_code: "20100", billing_vat_number: "12345678901", + billing_recipient_code: "ABCDEFG") + ClubMembership.create!(user: coach, club: c, role: "owner") + c.teams.create!(name: "U15", sport: "volleyball") + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: c, plan_slug: "free") + c + end + + def login! + post public_login_path, params: { email: coach.email, password: "password123" } + end + + it "mostra pagamenti e fatture in billing" do + club.billing_payments.create!( + amount_cents: 4000, currency: "eur", status: "paid", paid_at: Time.current, + description: "Premium Light", stripe_invoice_id: "in_spec_1" + ) + club.billing_invoices.create!( + number: "2026/100", issued_on: Date.current, amount_cents: 4000, status: "draft" + ) + + login! + get public_club_billing_path(club) + + expect(response).to have_http_status(:ok) + expect(response.body).to include("Pagamenti effettuati") + expect(response.body).to include("Fatture") + expect(response.body).not_to include("Aruba") + expect(response.body).to include("Premium Light") + expect(response.body).to include("2026/100") + end + + it "salva profilo fatturazione" do + club.update!(billing_recipient_code: nil, billing_pec: nil) + login! + patch public_club_billing_profile_path(club), params: { + club: { billing_recipient_code: "XYZABC1", billing_pec: "" } + } + expect(response).to redirect_to(public_club_billing_path(club)) + expect(club.reload.billing_recipient_code).to eq("XYZABC1") + end + + it "blocca checkout premium senza profilo completo" do + club.update!(billing_vat_number: nil, billing_fiscal_code: nil) + allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true) + + login! + get public_club_checkout_path(club, plan: "premium_light") + + expect(response).to redirect_to(public_club_billing_profile_path(club)) + end +end diff --git a/backend/spec/requests/public/regia_spec.rb b/backend/spec/requests/public/regia_spec.rb new file mode 100644 index 0000000..3213853 --- /dev/null +++ b/backend/spec/requests/public/regia_spec.rb @@ -0,0 +1,25 @@ +require "rails_helper" + +RSpec.describe "Public regia", type: :request do + let!(:user) { User.create!(email: "regia@test.it", name: "U", password: "password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball") } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") } + + it "mostra la pagina regia con token valido" do + token = Sessions::RegiaAccess.new(session).issue_token! + get public_regia_path(token) + expect(response).to have_http_status(:ok) + expect(response.body).to include("Condividi link regia") + expect(response.body).not_to include("Aruba") + end + + it "aggiorna il punteggio via API" do + token = Sessions::RegiaAccess.new(session).issue_token! + session.create_score_state!(home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1) + patch public_regia_score_path(token), params: { cmd: "home_point" } + expect(response).to have_http_status(:ok) + expect(session.score_state.reload.home_points).to eq(1) + end +end diff --git a/backend/spec/services/billing/stripe/change_plan_spec.rb b/backend/spec/services/billing/stripe/change_plan_spec.rb new file mode 100644 index 0000000..9b1c390 --- /dev/null +++ b/backend/spec/services/billing/stripe/change_plan_spec.rb @@ -0,0 +1,57 @@ +require "rails_helper" + +RSpec.describe Billing::Stripe::ChangePlan do + let(:club) { Club.create!(name: "Billing Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call( + club: club, + plan_slug: "premium_light", + status: "active", + stripe_attrs: { + stripe_customer_id: "cus_test", + stripe_subscription_id: "sub_test_123" + } + ) + allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true) + allow(MatchLiveTv).to receive(:stripe_premium_light_price_id).and_return("price_light") + allow(MatchLiveTv).to receive(:stripe_premium_full_price_id).and_return("price_full") + Plan.find_by!(slug: "premium_light").update!(stripe_price_id: "price_light") + Plan.find_by!(slug: "premium_full").update!(stripe_price_id: "price_full") + end + + it "aggiorna Stripe e il piano locale verso premium_full" do + stripe_item = double(id: "si_test") + stripe_sub = double( + id: "sub_test_123", + customer: "cus_test", + status: "active", + current_period_start: Time.current.to_i, + current_period_end: 1.year.from_now.to_i, + cancel_at_period_end: false, + items: double(data: [stripe_item]) + ) + updated_sub = stripe_sub + + allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub) + allow(Stripe::Subscription).to receive(:update).and_return(updated_sub) + + described_class.call(club: club, plan_slug: "premium_full") + + expect(Stripe::Subscription).to have_received(:update).with( + "sub_test_123", + hash_including( + items: [{ id: "si_test", price: "price_full" }], + metadata: { club_id: club.id, plan_slug: "premium_full" } + ) + ) + expect(club.reload.subscription.plan.slug).to eq("premium_full") + end + + it "rifiuta il cambio sullo stesso piano" do + expect { + described_class.call(club: club, plan_slug: "premium_light") + }.to raise_error(/già su questo piano/) + end +end diff --git a/backend/spec/services/billing/stripe/record_payment_spec.rb b/backend/spec/services/billing/stripe/record_payment_spec.rb new file mode 100644 index 0000000..18fd98b --- /dev/null +++ b/backend/spec/services/billing/stripe/record_payment_spec.rb @@ -0,0 +1,37 @@ +require "rails_helper" + +RSpec.describe Billing::Stripe::RecordPayment do + let(:club) { Club.create!(name: "Pay Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + + before do + load Rails.root.join("db/seeds/plans.rb") + Billing::AssignPlan.call(club: club, plan_slug: "premium_light", stripe_attrs: { stripe_subscription_id: "sub_x" }) + end + + it "crea un pagamento da fattura Stripe" do + invoice = double( + id: "in_test_1", + subscription: "sub_x", + metadata: { "club_id" => club.id }, + amount_paid: 4000, + currency: "eur", + status: "paid", + description: "Premium Light", + payment_intent: "pi_x", + charge: "ch_x", + invoice_pdf: "https://stripe.com/invoice.pdf", + hosted_invoice_url: "https://stripe.com/hosted", + status_transitions: double(paid_at: Time.current.to_i), + lines: double(data: [double(description: "Premium Light annuale")]), + subscription_details: nil + ) + allow(invoice).to receive(:metadata).and_return({ "club_id" => club.id }) + + payment = described_class.call(stripe_invoice: invoice) + + expect(payment).to be_persisted + expect(payment.club).to eq(club) + expect(payment.amount_cents).to eq(4000) + expect(payment.stripe_invoice_id).to eq("in_test_1") + end +end diff --git a/backend/spec/services/scoring/apply_action_spec.rb b/backend/spec/services/scoring/apply_action_spec.rb new file mode 100644 index 0000000..129d558 --- /dev/null +++ b/backend/spec/services/scoring/apply_action_spec.rb @@ -0,0 +1,15 @@ +require "rails_helper" + +RSpec.describe Scoring::ApplyAction do + let!(:user) { User.create!(email: "score@test.it", name: "U", password: "password123", role: "coach") } + let!(:club) { Club.create!(name: "C", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "T", sport: "volleyball") } + let!(:match) { team.matches.create!(opponent_name: "Opp", sport: "volleyball", sets_to_win: 3) } + let!(:session) { StreamSession.create!(match: match, user: user, platform: "matchlivetv", status: "live") } + + it "incrementa i punti casa" do + session.create_score_state!(home_sets: 0, away_sets: 0, home_points: 0, away_points: 0, current_set: 1) + result = described_class.new(session: session, action: "home_point").call + expect(result.score.home_points).to eq(1) + end +end diff --git a/backend/spec/services/teams/entitlements_spec.rb b/backend/spec/services/teams/entitlements_spec.rb index b184e91..140eabe 100644 --- a/backend/spec/services/teams/entitlements_spec.rb +++ b/backend/spec/services/teams/entitlements_spec.rb @@ -1,50 +1,18 @@ require "rails_helper" RSpec.describe Teams::Entitlements do - let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } - let(:team) { club.teams.create!(name: "Test Team", sport: "volleyball") } + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } before do load Rails.root.join("db/seeds/plans.rb") Billing::AssignPlan.call(club: club, plan_slug: "free") end - subject(:ent) { described_class.new(team) } - - it "allows matchlivetv on free" do - expect(ent.can_stream_on?("matchlivetv")).to be true - end - - it "denies youtube on free" do - expect { ent.assert_can_stream_on!("youtube") }.to raise_error(Teams::EntitlementError) - end - - it "allows youtube on premium full" do - Billing::AssignPlan.call(club: club, plan_slug: "premium_full") - expect(ent.can_stream_on?("youtube")).to be true - end - - it "enforces transmission staff limit on free" do - team.team_invitations.create!( - email: "camera@test.com", - token_digest: SecureRandom.hex(32), - role: "member", - staff_kind: "transmission", - expires_at: 7.days.from_now - ) - expect { ent.assert_can_invite!(staff_kind: "transmission") }.to raise_error(Teams::EntitlementError) do |e| - expect(e.code).to eq("staff_limit_reached") - end - end - - it "allows regia invite when only transmission slot is used on free" do - team.team_invitations.create!( - email: "camera@test.com", - token_digest: SecureRandom.hex(32), - role: "member", - staff_kind: "transmission", - expires_at: 7.days.from_now - ) - expect { ent.assert_can_invite!(staff_kind: "regia") }.not_to raise_error + it "limits transmission invites on free plan" do + user = User.create!(email: "t1@test.com", name: "T1", password: "password123", role: "coach") + UserTeam.create!(user: user, team: team, role: "member", staff_kind: "transmission") + ent = described_class.new(team) + expect { ent.assert_can_invite! }.to raise_error(Teams::EntitlementError, /trasmissione/) end end diff --git a/backend/spec/services/teams/staff_assignment_spec.rb b/backend/spec/services/teams/staff_assignment_spec.rb index 0a51e6d..0e37ce9 100644 --- a/backend/spec/services/teams/staff_assignment_spec.rb +++ b/backend/spec/services/teams/staff_assignment_spec.rb @@ -1,38 +1,28 @@ require "rails_helper" RSpec.describe Teams::StaffAssignment do - let(:club) { Club.create!(name: "Test Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } - let(:team) { club.teams.create!(name: "Test", sport: "volleyball") } - let(:owner) { User.create!(name: "Coach", email: "owner@test.com", password: "password123", role: "coach") } + let!(:owner) { User.create!(name: "Owner", email: "owner@test.com", password: "password123", role: "coach") } + let!(:club) { Club.create!(name: "Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let!(:team) { club.teams.create!(name: "Team", sport: "volleyball") } + let!(:other) { User.create!(name: "Other", email: "other@test.com", password: "password123", role: "coach") } before do + ClubMembership.create!(user: owner, club: club, role: "owner") load Rails.root.join("db/seeds/plans.rb") Billing::AssignPlan.call(club: club, plan_slug: "free") - ClubMembership.create!(user: owner, club: club, role: "owner") end - it "assigns owner as transmission staff" do + it "assigns transmission to owner" do membership = UserTeam.create!(user: owner, team: team, role: "member") - described_class.call(team: team, user: owner, staff_kind: "transmission", membership: membership) + described_class.call(team: team, user: owner, membership: membership) expect(membership.reload.staff_kind).to eq("transmission") expect(team.entitlements.staff_count_for("transmission")).to eq(1) end - it "blocks same email for transmission and regia" do - membership = UserTeam.create!(user: owner, team: team, role: "member") - described_class.call(team: team, user: owner, staff_kind: "transmission", membership: membership) - + it "rejects duplicate email for transmission" do + UserTeam.create!(user: other, team: team, role: "member", staff_kind: "transmission") expect { - described_class.call(team: team, user: owner, staff_kind: "regia", membership: membership) - }.to raise_error(Teams::StaffAssignmentError, /email diverse/i) - end - - it "blocks invite when email already used for other staff kind" do - membership = UserTeam.create!(user: owner, team: team, role: "member") - described_class.call(team: team, user: owner, staff_kind: "transmission", membership: membership) - - expect { - Teams::StaffEmailValidator.assert_available!(team: team, email: owner.email, staff_kind: "regia") - }.to raise_error(Teams::StaffAssignmentError, /già assegnata/i) + Teams::StaffEmailValidator.assert_available!(team: team, email: other.email) + }.to raise_error(Teams::StaffAssignmentError, /già assegnata/) end end diff --git a/backend/spec/services/webhooks/mediamtx_handler_spec.rb b/backend/spec/services/webhooks/mediamtx_handler_spec.rb index b16125f..912ddf3 100644 --- a/backend/spec/services/webhooks/mediamtx_handler_spec.rb +++ b/backend/spec/services/webhooks/mediamtx_handler_spec.rb @@ -2,7 +2,8 @@ require "rails_helper" RSpec.describe Webhooks::MediamtxHandler do let(:user) { User.create!(email: "c@test.com", name: "C", password: "password123", role: "coach") } - let(:team) { Team.create!(name: "T") } + let(:club) { Club.create!(name: "MTX Club", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let(:team) { club.teams.create!(name: "T", sport: "volleyball") } let!(:match) { team.matches.create!(opponent_name: "Away") } let!(:session) do StreamSession.create!( diff --git a/docs/PRODUCT_PREMIUM.md b/docs/PRODUCT_PREMIUM.md index 6e4d7fa..792d1ef 100644 --- a/docs/PRODUCT_PREMIUM.md +++ b/docs/PRODUCT_PREMIUM.md @@ -4,8 +4,7 @@ | | Free | Premium Light | Premium Full | |---|------|---------------|----------------| -| Staff trasmissione / anno | 1 | 5 | Illimitato | -| Staff regia / anno | 1 | 5 | Illimitato | +| Responsabili trasmissione / anno | 1 | 5 | Illimitato | | Partite in contemporanea | 1 | 3 | 10 | | Live Match Live TV | Sì | Sì | Sì | | YouTube | No | Match TV Light | Canale società | @@ -14,6 +13,8 @@ | Prezzo | €0 | €40/anno o €5/mese | €200/anno o €20/mese | | Embed sito società | No | No | In arrivo | +Il punteggio in diretta si gestisce tramite **link regia** condivisibile (WhatsApp, email, SMS…): non serve un account per chi fa il tabellone. + Esigenze custom: contatto `info@matchlive.it`. ## Sito @@ -22,6 +23,7 @@ Esigenze custom: contatto `info@matchlive.it`. - `/funzionalita` — Feature - `/prezzi` — Prezzi (statici in pagina) - `/live` — Dirette pubbliche +- `/regia/:token` — Pannello punteggio mobile (link condiviso) - `/signup` — Registrazione → squadra → dashboard ## Stripe diff --git a/infra/.env.example b/infra/.env.example index 7d4b1fc..8fd2a74 100644 --- a/infra/.env.example +++ b/infra/.env.example @@ -11,3 +11,9 @@ PRIVACY_CONTROLLER_ADDRESS=Via Guido De Ruggiero, 89 - 20142 - Milano (MI) PRIVACY_CONTACT_EMAIL=privacy@matchlive.it MAILER_FROM=Match Live TV PASSWORD_RESET_EXPIRY_HOURS=2 + +# Stripe (test: sk_test_... / price_... da Dashboard modalità Test) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PREMIUM_LIGHT_PRICE_ID= +STRIPE_PREMIUM_FULL_PRICE_ID= diff --git a/infra/docker-compose.prod.yml b/infra/docker-compose.prod.yml index 49e292c..0fad3ff 100644 --- a/infra/docker-compose.prod.yml +++ b/infra/docker-compose.prod.yml @@ -85,6 +85,10 @@ services: SMTP_STARTTLS: ${SMTP_STARTTLS:-true} RAILS_LOG_TO_STDOUT: "true" RAILS_LOG_LEVEL: ${RAILS_LOG_LEVEL:-info} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} + STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} ports: - "3000:3000" # Solo LAN — NPM proxy HTTPS depends_on: diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml index d5bfee5..d9a4e56 100644 --- a/infra/docker-compose.yml +++ b/infra/docker-compose.yml @@ -50,9 +50,11 @@ services: build: context: ../backend dockerfile: Dockerfile - command: bash -c "bundle exec rails db:prepare db:seed && bundle exec rails server -b 0.0.0.0 -p 3000" + command: bash -c "bundle check || bundle install && bundle exec rails db:prepare db:seed && bundle exec rails server -b 0.0.0.0 -p 3000" environment: RAILS_ENV: development + BUNDLE_WITHOUT: "" + BUNDLE_DEPLOYMENT: "0" DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv REDIS_URL: redis://redis:6379/0 MEDIAMTX_API_URL: http://mediamtx:9997 @@ -69,6 +71,11 @@ services: PRIVACY_CONTROLLER_ADDRESS: ${PRIVACY_CONTROLLER_ADDRESS:-Via Guido De Ruggiero, 89 - 20142 - Milano (MI)} PRIVACY_CONTACT_EMAIL: ${PRIVACY_CONTACT_EMAIL:-privacy@matchlive.it} MAILER_FROM: ${MAILER_FROM:-Match Live TV } + APP_PUBLIC_URL: ${APP_PUBLIC_URL:-http://localhost:3000} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PREMIUM_LIGHT_PRICE_ID: ${STRIPE_PREMIUM_LIGHT_PRICE_ID:-} + STRIPE_PREMIUM_FULL_PRICE_ID: ${STRIPE_PREMIUM_FULL_PRICE_ID:-} ports: - "3000:3000" depends_on: @@ -81,6 +88,7 @@ services: volumes: - ../backend:/app - recordings:/recordings + - bundle_cache:/usr/local/bundle healthcheck: test: ["CMD", "curl", "-f", "http://localhost:3000/up"] interval: 15s @@ -95,6 +103,8 @@ services: command: bundle exec sidekiq -C config/sidekiq.yml environment: RAILS_ENV: development + BUNDLE_WITHOUT: "" + BUNDLE_DEPLOYMENT: "0" DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-matchlivetv_dev}@postgres:5432/matchlivetv REDIS_URL: redis://redis:6379/0 MEDIAMTX_API_URL: http://mediamtx:9997 @@ -109,6 +119,7 @@ services: condition: service_healthy volumes: - ../backend:/app + - bundle_cache:/usr/local/bundle nginx: image: nginx:alpine @@ -123,3 +134,4 @@ volumes: postgres_data: redis_data: recordings: + bundle_cache: diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart index 566306b..033b4e4 100644 --- a/mobile/lib/app/router.dart +++ b/mobile/lib/app/router.dart @@ -5,7 +5,6 @@ import 'package:go_router/go_router.dart'; import '../features/auth/login_screen.dart'; import '../features/auth/splash_screen.dart'; import '../features/camera_mode/camera_screen.dart'; -import '../features/controller_mode/controller_screen.dart'; import '../features/archive/archive_screen.dart'; import '../features/matches/matches_screen.dart'; import '../features/setup_wizard/wizard_shell.dart'; @@ -78,13 +77,6 @@ final routerProvider = Provider((ref) { return CameraScreen(sessionId: sessionId); }, ), - GoRoute( - path: '/session/:id/controller', - builder: (context, state) { - final sessionId = state.pathParameters['id']!; - return ControllerScreen(sessionId: sessionId); - }, - ), ], ); }); diff --git a/mobile/lib/core/team_selection_storage.dart b/mobile/lib/core/team_selection_storage.dart new file mode 100644 index 0000000..f442fbd --- /dev/null +++ b/mobile/lib/core/team_selection_storage.dart @@ -0,0 +1,22 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class TeamSelectionStorage { + static const _keySelectedTeamId = 'selected_team_id'; + + static Future load() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getString(_keySelectedTeamId); + if (id == null || id.isEmpty) return null; + return id; + } + + static Future save(String teamId) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keySelectedTeamId, teamId); + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keySelectedTeamId); + } +} diff --git a/mobile/lib/features/archive/archive_screen.dart b/mobile/lib/features/archive/archive_screen.dart index 6a38c52..e300357 100644 --- a/mobile/lib/features/archive/archive_screen.dart +++ b/mobile/lib/features/archive/archive_screen.dart @@ -13,7 +13,7 @@ class ArchiveScreen extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final teamAsync = ref.watch(primaryTeamProvider); + final teamAsync = ref.watch(activeTeamProvider); final theme = Theme.of(context); return Scaffold( diff --git a/mobile/lib/features/camera_mode/camera_screen.dart b/mobile/lib/features/camera_mode/camera_screen.dart index 0c5e8e9..2b382a0 100644 --- a/mobile/lib/features/camera_mode/camera_screen.dart +++ b/mobile/lib/features/camera_mode/camera_screen.dart @@ -14,8 +14,8 @@ import '../../platform/streaming_channel.dart'; import '../../platform/streaming_preview.dart'; import '../../providers/match_rules_provider.dart'; import '../../providers/score_provider.dart'; -import '../shared/live_score_actions.dart'; import '../../shared/api_client.dart'; +import '../../shared/regia_share.dart'; import '../../shared/models/score_state.dart'; import '../../providers/session_provider.dart'; import '../../shared/websocket_service.dart'; @@ -120,8 +120,6 @@ class _CameraScreenState extends ConsumerState { final match = await client.fetchMatch(session.matchId); ref.read(sessionProvider.notifier).setSession(session, match: match); - ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera); - if (session.score != null) { ref.read(scoreProvider.notifier).reset(session.score!); } @@ -223,60 +221,14 @@ class _CameraScreenState extends ConsumerState { await SystemChrome.setPreferredOrientations(DeviceOrientation.values); } - void _syncScore() { - ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); - } - - Future _homePoint() async { - ref.read(scoreProvider.notifier).incrementHome(); - _syncScore(); + Future _shareRegia() async { final match = ref.read(sessionProvider).match; - await LiveScoreActions(ref).afterPointChange( + await shareRegiaLink( context, - homeName: match?.teamName ?? 'Casa', - awayName: match?.opponentName ?? 'Ospiti', - onCloseSetConfirmed: () => _closeSetConfirmed(), - ); - } - - Future _awayPoint() async { - ref.read(scoreProvider.notifier).incrementAway(); - _syncScore(); - final match = ref.read(sessionProvider).match; - await LiveScoreActions(ref).afterPointChange( - context, - homeName: match?.teamName ?? 'Casa', - awayName: match?.opponentName ?? 'Ospiti', - onCloseSetConfirmed: () => _closeSetConfirmed(), - ); - } - - void _homeMinus() { - ref.read(scoreProvider.notifier).decrementHome(); - _syncScore(); - } - - void _awayMinus() { - ref.read(scoreProvider.notifier).decrementAway(); - _syncScore(); - } - - Future _closeSet() async { - await LiveScoreActions(ref).requestCloseSet( - context, - onCloseSetConfirmed: () => _closeSetConfirmed(), - ); - } - - Future _closeSetConfirmed() async { - ref.read(scoreProvider.notifier).closeSet(); - _syncScore(); - final match = ref.read(sessionProvider).match; - await LiveScoreActions(ref).afterCloseSet( - context, - homeName: match?.teamName ?? 'Casa', - awayName: match?.opponentName ?? 'Ospiti', - onStopStream: _closeStreamPermanently, + ref, + sessionId: widget.sessionId, + shareSubject: + 'Regia — ${match?.teamName ?? 'Casa'} vs ${match?.opponentName ?? 'Ospiti'}', ); } @@ -390,11 +342,7 @@ class _CameraScreenState extends ConsumerState { bitrateMbps: _bitrateMbps, fps: _fps, viewerCount: _viewerCount, - onHomePoint: () => _homePoint(), - onAwayPoint: () => _awayPoint(), - onHomeMinus: _homeMinus, - onAwayMinus: _awayMinus, - onCloseSet: () => _closeSet(), + onShareRegia: _shareRegia, onInterrupt: _interruptStream, onCloseStream: _closeStreamPermanently, pointsTarget: pointsTarget, @@ -410,11 +358,7 @@ class _CameraScreenState extends ConsumerState { bitrateMbps: _bitrateMbps, fps: _fps, viewerCount: _viewerCount, - onHomePoint: () => _homePoint(), - onAwayPoint: () => _awayPoint(), - onHomeMinus: _homeMinus, - onAwayMinus: _awayMinus, - onCloseSet: () => _closeSet(), + onShareRegia: _shareRegia, onInterrupt: _interruptStream, onCloseStream: _closeStreamPermanently, pointsTarget: pointsTarget, @@ -436,11 +380,7 @@ class _PortraitOverlay extends StatelessWidget { required this.bitrateMbps, required this.fps, required this.viewerCount, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, + required this.onShareRegia, required this.onInterrupt, required this.onCloseStream, required this.pointsTarget, @@ -455,11 +395,7 @@ class _PortraitOverlay extends StatelessWidget { final double bitrateMbps; final int fps; final int viewerCount; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; + final VoidCallback onShareRegia; final VoidCallback onInterrupt; final Future Function() onCloseStream; final int pointsTarget; @@ -476,6 +412,11 @@ class _PortraitOverlay extends StatelessWidget { children: [ LiveBadge(elapsed: elapsed), const Spacer(), + IconButton( + onPressed: onShareRegia, + icon: const Icon(Icons.share), + tooltip: 'Condividi link regia', + ), CameraCompactMetrics( networkType: networkType, bitrateMbps: bitrateMbps, @@ -507,13 +448,14 @@ class _PortraitOverlay extends StatelessWidget { homeSets: score.homeSets, awaySets: score.awaySets, pointsTarget: pointsTarget, - onHomePoint: onHomePoint, - onAwayPoint: onAwayPoint, - onHomeMinus: onHomeMinus, - onAwayMinus: onAwayMinus, - onCloseSet: onCloseSet, ), const SizedBox(height: 10), + OutlinedButton.icon( + onPressed: onShareRegia, + icon: const Icon(Icons.share, size: 18), + label: const Text('Condividi link regia'), + ), + const SizedBox(height: 8), OutlinedButton( onPressed: onInterrupt, child: const Text('Interrompi (riprendibile)'), @@ -543,11 +485,7 @@ class _LandscapeOverlay extends StatelessWidget { required this.bitrateMbps, required this.fps, required this.viewerCount, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, + required this.onShareRegia, required this.onInterrupt, required this.onCloseStream, required this.pointsTarget, @@ -563,11 +501,7 @@ class _LandscapeOverlay extends StatelessWidget { final double bitrateMbps; final int fps; final int viewerCount; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; + final VoidCallback onShareRegia; final VoidCallback onInterrupt; final Future Function() onCloseStream; final int pointsTarget; @@ -618,15 +552,15 @@ class _LandscapeOverlay extends StatelessWidget { awaySets: score.awaySets, lastDelta: lastDelta, pointsTarget: pointsTarget, - onHomePoint: onHomePoint, - onAwayPoint: onAwayPoint, - onHomeMinus: onHomeMinus, - onAwayMinus: onAwayMinus, - onCloseSet: onCloseSet, ), const SizedBox(height: 8), Row( children: [ + IconButton( + onPressed: onShareRegia, + icon: const Icon(Icons.share, color: Colors.white), + tooltip: 'Condividi link regia', + ), Expanded( child: OutlinedButton( onPressed: onInterrupt, diff --git a/mobile/lib/features/controller_mode/controller_landscape.dart b/mobile/lib/features/controller_mode/controller_landscape.dart deleted file mode 100644 index 4791039..0000000 --- a/mobile/lib/features/controller_mode/controller_landscape.dart +++ /dev/null @@ -1,188 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; -import '../../providers/session_provider.dart'; -import '../../shared/models/score_state.dart'; -import '../../shared/widgets/connected_badge.dart'; -import '../../shared/widgets/destructive_cta.dart'; -import 'controller_screen.dart'; - -/// Layout gamepad a 3 colonne per uso landscape. -class ControllerLandscape extends StatelessWidget { - const ControllerLandscape({ - super.key, - required this.homeName, - required this.awayName, - required this.score, - required this.sessionState, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, - required this.onInterrupt, - required this.onCloseStream, - required this.pointsTarget, - }); - - final String homeName; - final String awayName; - final ScoreState score; - final SessionState sessionState; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; - final VoidCallback onInterrupt; - final Future Function() onCloseStream; - final int pointsTarget; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return SingleChildScrollView( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: _TeamColumn( - teamLabel: 'CASA', - teamName: homeName, - sets: score.homeSets, - points: score.homePoints, - onPoint: onHomePoint, - onMinus: onHomeMinus, - ), - ), - Expanded( - flex: 2, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('REGIA', style: theme.textTheme.titleMedium), - const SizedBox(width: 12), - ConnectedBadge(connected: sessionState.connected), - ], - ), - const SizedBox(height: 8), - Text( - 'SET ${score.currentSet} → $pointsTarget pt', - style: theme.textTheme.headlineMedium?.copyWith( - color: AppTheme.accentYellow, - ), - ), - const SizedBox(height: 16), - YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet), - const SizedBox(height: 16), - Text('ULTIME AZIONI', style: theme.textTheme.labelLarge), - const SizedBox(height: 8), - StreamMetricsFooter( - elapsed: sessionState.elapsed, - connected: sessionState.connected, - cameraDevice: sessionState.cameraDevice, - viewerCount: sessionState.viewerCount, - compact: true, - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: onInterrupt, - child: const Text('Interrompi'), - ), - ), - const SizedBox(width: 8), - Expanded( - child: DestructiveCta( - label: 'Chiudi', - compact: true, - onPressed: () => onCloseStream(), - ), - ), - ], - ), - ], - ), - ), - ), - Expanded( - child: _TeamColumn( - teamLabel: 'OSPITI', - teamName: awayName, - sets: score.awaySets, - points: score.awayPoints, - onPoint: onAwayPoint, - onMinus: onAwayMinus, - alignRight: true, - ), - ), - ], - ), - ); - } -} - -class _TeamColumn extends StatelessWidget { - const _TeamColumn({ - required this.teamLabel, - required this.teamName, - required this.sets, - required this.points, - required this.onPoint, - required this.onMinus, - this.alignRight = false, - }); - - final String teamLabel; - final String teamName; - final int sets; - final int points; - final VoidCallback onPoint; - final VoidCallback onMinus; - final bool alignRight; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final crossAlign = - alignRight ? CrossAxisAlignment.end : CrossAxisAlignment.start; - - return Column( - crossAxisAlignment: crossAlign, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text(teamLabel, style: theme.textTheme.labelLarge), - Text( - teamName.toUpperCase(), - style: theme.textTheme.titleLarge, - textAlign: alignRight ? TextAlign.right : TextAlign.left, - ), - const SizedBox(height: 8), - Text('SETS $sets', style: theme.textTheme.bodyMedium), - Text( - '$points', - style: theme.textTheme.displayLarge?.copyWith( - color: AppTheme.primaryRed, - fontSize: 56, - ), - ), - const SizedBox(height: 16), - ScoreTapButton(label: '+1', onTap: onPoint, large: true), - const SizedBox(height: 8), - OutlinedButton( - onPressed: points > 0 ? onMinus : null, - child: const Text('-1'), - ), - ], - ); - } -} diff --git a/mobile/lib/features/controller_mode/controller_portrait.dart b/mobile/lib/features/controller_mode/controller_portrait.dart deleted file mode 100644 index 72c611a..0000000 --- a/mobile/lib/features/controller_mode/controller_portrait.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../app/theme.dart'; -import '../../providers/session_provider.dart'; -import '../../shared/models/score_state.dart'; -import '../../shared/widgets/destructive_cta.dart'; -import '../../shared/widgets/screen_insets.dart'; -import 'controller_screen.dart'; - -class ControllerPortrait extends StatelessWidget { - const ControllerPortrait({ - super.key, - required this.homeName, - required this.awayName, - required this.score, - required this.sessionState, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, - required this.onInterrupt, - required this.onCloseStream, - required this.pointsTarget, - }); - - final String homeName; - final String awayName; - final ScoreState score; - final SessionState sessionState; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; - final VoidCallback onInterrupt; - final Future Function() onCloseStream; - final int pointsTarget; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final match = sessionState.match; - - return SingleChildScrollView( - padding: ScreenInsets.contentPadding(context, horizontal: 16, vertical: 8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (match != null) - Text( - '${match.sport.toUpperCase()} · ${match.category ?? ''} · ${match.phase ?? ''} · SET ${score.currentSet}', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - _Scoreboard( - homeName: homeName, - awayName: awayName, - score: score, - pointsTarget: pointsTarget, - onHomePoint: onHomePoint, - onAwayPoint: onAwayPoint, - onHomeMinus: onHomeMinus, - onAwayMinus: onAwayMinus, - ), - const SizedBox(height: 12), - YellowActionButton(label: 'CHIUDI SET', onPressed: onCloseSet), - const SizedBox(height: 16), - StreamMetricsFooter( - elapsed: sessionState.elapsed, - connected: sessionState.connected, - cameraDevice: sessionState.cameraDevice, - viewerCount: sessionState.viewerCount, - ), - const SizedBox(height: 16), - OutlinedButton( - onPressed: onInterrupt, - child: const Text('Interrompi (riprendibile)'), - ), - const SizedBox(height: 8), - DestructiveCta( - label: 'Chiudi diretta', - onPressed: () => onCloseStream(), - ), - ], - ), - ); - } -} - -class _Scoreboard extends StatelessWidget { - const _Scoreboard({ - required this.homeName, - required this.awayName, - required this.score, - required this.pointsTarget, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - }); - - final String homeName; - final String awayName; - final ScoreState score; - final int pointsTarget; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(12), - ), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: Column( - children: [ - Text(homeName.toUpperCase(), style: theme.textTheme.labelLarge), - Text( - 'SETS ${score.homeSets}', - style: theme.textTheme.bodyMedium, - ), - Text( - '${score.homePoints}', - style: theme.textTheme.displayMedium?.copyWith( - color: AppTheme.primaryRed, - ), - ), - Text('→ $pointsTarget pt', style: theme.textTheme.bodySmall), - const SizedBox(height: 8), - ScoreTapButton(label: '+1', onTap: onHomePoint), - const SizedBox(height: 6), - OutlinedButton( - onPressed: score.homePoints > 0 ? onHomeMinus : null, - child: const Text('-1'), - ), - ], - ), - ), - Text( - '-', - style: theme.textTheme.headlineLarge?.copyWith( - color: AppTheme.textSecondary, - ), - ), - Expanded( - child: Column( - children: [ - Text(awayName.toUpperCase(), style: theme.textTheme.labelLarge), - Text( - 'SETS ${score.awaySets}', - style: theme.textTheme.bodyMedium, - ), - Text( - '${score.awayPoints}', - style: theme.textTheme.displayMedium?.copyWith( - color: AppTheme.primaryRed, - ), - ), - const SizedBox(height: 8), - ScoreTapButton(label: '+1', onTap: onAwayPoint), - const SizedBox(height: 6), - OutlinedButton( - onPressed: score.awayPoints > 0 ? onAwayMinus : null, - child: const Text('-1'), - ), - ], - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/mobile/lib/features/controller_mode/controller_screen.dart b/mobile/lib/features/controller_mode/controller_screen.dart deleted file mode 100644 index ffb86bb..0000000 --- a/mobile/lib/features/controller_mode/controller_screen.dart +++ /dev/null @@ -1,424 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; - -import '../../app/theme.dart'; -import '../../providers/match_rules_provider.dart'; -import '../../providers/score_provider.dart'; -import '../../providers/session_provider.dart'; -import '../shared/live_score_actions.dart'; -import '../../shared/widgets/end_stream_dialog.dart'; -import '../../shared/api_client.dart'; -import '../../shared/websocket_service.dart'; -import '../../shared/widgets/connected_badge.dart'; -import '../../shared/widgets/match_live_wordmark.dart'; -import '../../shared/widgets/screen_insets.dart'; -import 'controller_landscape.dart'; -import 'controller_portrait.dart'; - -class ControllerScreen extends ConsumerStatefulWidget { - const ControllerScreen({super.key, required this.sessionId}); - - final String sessionId; - - @override - ConsumerState createState() => _ControllerScreenState(); -} - -class _ControllerScreenState extends ConsumerState { - Timer? _elapsedTimer; - Timer? _statsTimer; - - @override - void initState() { - super.initState(); - _lockLandscape(); - _bootstrap(); - _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { - final session = ref.read(sessionProvider).session; - if (session?.startedAt != null) { - ref.read(sessionProvider.notifier).setElapsed( - DateTime.now().difference(session!.startedAt!), - ); - } - }); - _statsTimer = Timer.periodic(const Duration(seconds: 30), (_) => _fetchStats()); - } - - Future _bootstrap() async { - try { - final client = ref.read(apiClientProvider); - final session = await client.fetchSession(widget.sessionId); - ref.read(sessionProvider.notifier).setSession(session); - if (session.score != null) { - ref.read(scoreProvider.notifier).reset(session.score!); - } - final ws = ref.read(websocketServiceProvider); - await ws.connect(sessionId: widget.sessionId, deviceRole: 'controller'); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Errore connessione sessione')), - ); - } - } - } - - Future _fetchStats() async { - try { - final client = ref.read(apiClientProvider); - final stats = await client.youtubeStats(widget.sessionId); - ref.read(sessionProvider.notifier).setViewerCount(stats.concurrentViewers); - } catch (_) {} - } - - Future _lockLandscape() async { - await SystemChrome.setPreferredOrientations([ - DeviceOrientation.landscapeLeft, - DeviceOrientation.landscapeRight, - DeviceOrientation.portraitUp, - ]); - } - - Future _unlockOrientation() async { - await SystemChrome.setPreferredOrientations(DeviceOrientation.values); - } - - Future _leaveSession() async { - try { - await ref.read(websocketServiceProvider).disconnect(); - } catch (_) {} - if (mounted) context.go('/matches'); - } - - Future _interruptStream() async { - try { - ref.read(websocketServiceProvider).sendCommand('pause_stream'); - await ref.read(apiClientProvider).pauseSession(widget.sessionId); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Pausa: $e')), - ); - } - } - await _leaveSession(); - } - - Future _closeStreamPermanently() async { - if (!mounted) return; - final ok = await confirmEndStream(context); - if (!ok || !mounted) return; - - try { - ref.read(websocketServiceProvider).sendCommand('stop_stream'); - } catch (_) {} - try { - await ref.read(apiClientProvider).stopSession(widget.sessionId); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('Chiusura: $e')), - ); - } - } - await _leaveSession(); - } - - @override - void dispose() { - _elapsedTimer?.cancel(); - _statsTimer?.cancel(); - _unlockOrientation(); - super.dispose(); - } - - void _syncScore() { - ref.read(websocketServiceProvider).sendScoreUpdate(ref.read(scoreProvider)); - } - - Future _homePoint() async { - ref.read(scoreProvider.notifier).incrementHome(); - _syncScore(); - final match = ref.read(sessionProvider).match; - await LiveScoreActions(ref).afterPointChange( - context, - homeName: match?.teamName ?? 'Casa', - awayName: match?.opponentName ?? 'Ospiti', - onCloseSetConfirmed: () => _closeSetConfirmed(), - ); - } - - Future _awayPoint() async { - ref.read(scoreProvider.notifier).incrementAway(); - _syncScore(); - final match = ref.read(sessionProvider).match; - await LiveScoreActions(ref).afterPointChange( - context, - homeName: match?.teamName ?? 'Casa', - awayName: match?.opponentName ?? 'Ospiti', - onCloseSetConfirmed: () => _closeSetConfirmed(), - ); - } - - void _homeMinus() { - ref.read(scoreProvider.notifier).decrementHome(); - _syncScore(); - } - - void _awayMinus() { - ref.read(scoreProvider.notifier).decrementAway(); - _syncScore(); - } - - Future _closeSet() async { - await LiveScoreActions(ref).requestCloseSet( - context, - onCloseSetConfirmed: () => _closeSetConfirmed(), - ); - } - - Future _closeSetConfirmed() async { - ref.read(scoreProvider.notifier).closeSet(); - _syncScore(); - final match = ref.read(sessionProvider).match; - await LiveScoreActions(ref).afterCloseSet( - context, - homeName: match?.teamName ?? 'Casa', - awayName: match?.opponentName ?? 'Ospiti', - onStopStream: _closeStreamPermanently, - ); - } - - @override - Widget build(BuildContext context) { - final sessionState = ref.watch(sessionProvider); - final score = ref.watch(scoreProvider); - final match = sessionState.match; - final isLandscape = - MediaQuery.orientationOf(context) == Orientation.landscape; - - final homeName = match?.teamName ?? 'CASA'; - final awayName = match?.opponentName ?? 'OSPITI'; - final rules = ref.watch(matchScoringRulesProvider); - final pointsTarget = rules.pointsTarget(score.currentSet); - - final body = isLandscape - ? ControllerLandscape( - homeName: homeName, - awayName: awayName, - score: score, - sessionState: sessionState, - pointsTarget: pointsTarget, - onHomePoint: () => _homePoint(), - onAwayPoint: () => _awayPoint(), - onHomeMinus: _homeMinus, - onAwayMinus: _awayMinus, - onCloseSet: () => _closeSet(), - onInterrupt: _interruptStream, - onCloseStream: _closeStreamPermanently, - ) - : ControllerPortrait( - homeName: homeName, - awayName: awayName, - score: score, - sessionState: sessionState, - pointsTarget: pointsTarget, - onHomePoint: () => _homePoint(), - onAwayPoint: () => _awayPoint(), - onHomeMinus: _homeMinus, - onAwayMinus: _awayMinus, - onCloseSet: () => _closeSet(), - onInterrupt: _interruptStream, - onCloseStream: _closeStreamPermanently, - ); - - final inset = ScreenInsets.of(context); - - return Scaffold( - backgroundColor: AppTheme.background, - body: Column( - children: [ - if (!isLandscape) - Padding( - padding: EdgeInsets.fromLTRB( - inset.left, - inset.top, - inset.right, - 8, - ), - child: Row( - children: [ - const MatchLiveWordmark(compact: true), - const Spacer(), - ConnectedBadge(connected: sessionState.connected), - ], - ), - ), - Expanded( - child: Padding( - padding: EdgeInsets.only( - left: inset.left, - right: inset.right, - top: isLandscape ? inset.top : 0, - bottom: inset.bottom, - ), - child: body, - ), - ), - ], - ), - ); - } -} - -/// Pulsante score +1 con stile gamepad. -class ScoreTapButton extends StatelessWidget { - const ScoreTapButton({ - super.key, - required this.label, - required this.onTap, - this.large = false, - }); - - final String label; - final VoidCallback onTap; - final bool large; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Material( - color: AppTheme.surfaceElevated, - borderRadius: BorderRadius.circular(large ? 16 : 12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(large ? 16 : 12), - child: Container( - width: large ? double.infinity : null, - padding: EdgeInsets.symmetric( - vertical: large ? 20 : 12, - horizontal: large ? 24 : 16, - ), - child: Text( - label, - textAlign: TextAlign.center, - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w900, - fontSize: large ? 28 : 18, - ), - ), - ), - ), - ); - } -} - -/// CTA gialla per azioni secondarie (PAUSA, CHIUDI SET). -class YellowActionButton extends StatelessWidget { - const YellowActionButton({ - super.key, - required this.label, - required this.onPressed, - }); - - final String label; - final VoidCallback onPressed; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return SizedBox( - width: double.infinity, - child: ElevatedButton( - onPressed: onPressed, - style: ElevatedButton.styleFrom( - backgroundColor: AppTheme.accentYellow, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 14), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - child: Text( - label, - style: theme.textTheme.labelLarge?.copyWith(color: Colors.black), - ), - ), - ); - } -} - -/// Footer metriche stream (timer, segnale, Mbps, batteria). -class StreamMetricsFooter extends StatelessWidget { - const StreamMetricsFooter({ - super.key, - required this.elapsed, - required this.connected, - this.cameraDevice, - this.viewerCount, - this.compact = false, - }); - - final Duration elapsed; - final bool connected; - final dynamic cameraDevice; - final int? viewerCount; - final bool compact; - - String _format(Duration d) { - final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); - final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); - return '$m:$s'; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final network = cameraDevice?.networkType ?? '—'; - final mbps = cameraDevice?.bitrateMbps != null - ? cameraDevice.bitrateMbps.toStringAsFixed(1) - : '—'; - final battery = cameraDevice?.batteryLevel?.toString() ?? '—'; - - return Container( - padding: EdgeInsets.all(compact ? 8 : 12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(compact ? 8 : 10), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _MetricChip(icon: Icons.timer, label: _format(elapsed)), - _MetricChip(icon: Icons.signal_cellular_alt, label: network), - _MetricChip(icon: Icons.speed, label: '$mbps Mbps'), - _MetricChip(icon: Icons.battery_std, label: '$battery%'), - if (viewerCount != null) - _MetricChip(icon: Icons.visibility, label: '$viewerCount'), - ConnectedBadge(connected: connected, label: compact ? null : 'COLLEGATO'), - ], - ), - ); - } -} - -class _MetricChip extends StatelessWidget { - const _MetricChip({required this.icon, required this.label}); - - final IconData icon; - final String label; - - @override - Widget build(BuildContext context) { - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14, color: AppTheme.textSecondary), - const SizedBox(width: 4), - Text(label, style: Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 11)), - ], - ); - } -} diff --git a/mobile/lib/features/matches/matches_screen.dart b/mobile/lib/features/matches/matches_screen.dart index 7113485..15d8af2 100644 --- a/mobile/lib/features/matches/matches_screen.dart +++ b/mobile/lib/features/matches/matches_screen.dart @@ -14,26 +14,9 @@ import '../../shared/widgets/screen_insets.dart'; import 'resume_session_sheet.dart'; import 'no_team_onboarding.dart'; import 'schedule_match_sheet.dart'; +import 'team_picker.dart'; import 'team_providers.dart'; -final matchesProvider = FutureProvider>((ref) async { - final auth = ref.watch(authProvider); - if (!auth.isAuthenticated) return []; - final teams = await ref.read(teamsProvider.future); - if (teams.isEmpty) return []; - final client = ref.read(apiClientProvider); - final list = await client.fetchMatches(teams.first.id); - list.sort((a, b) { - final sa = a.scheduledAt; - final sb = b.scheduledAt; - if (sa == null && sb == null) return 0; - if (sa == null) return 1; - if (sb == null) return -1; - return sa.compareTo(sb); - }); - return list; -}); - class MatchesScreen extends ConsumerWidget { const MatchesScreen({super.key}); @@ -54,7 +37,7 @@ class MatchesScreen extends ConsumerWidget { icon: const Icon(Icons.group_outlined), tooltip: 'Gestisci staff', onPressed: () async { - final team = await ref.read(primaryTeamProvider.future); + final team = await ref.read(activeTeamProvider.future); final url = team?.staffManageUrl ?? team?.billingUrl; if (url != null && await canLaunchUrl(Uri.parse(url))) { await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); @@ -126,6 +109,7 @@ class MatchesScreen extends ConsumerWidget { return RefreshIndicator( color: AppTheme.primaryRed, onRefresh: () async { + ref.invalidate(teamsProvider); ref.invalidate(matchesProvider); await ref.read(matchesProvider.future); }, @@ -146,6 +130,7 @@ class MatchesScreen extends ConsumerWidget { 'Le tue partite', style: theme.textTheme.bodyMedium, ), + const TeamPickerBar(), if (activeMatch != null) ...[ const SizedBox(height: 12), Material( @@ -315,8 +300,8 @@ class MatchesScreen extends ConsumerWidget { } Future _scheduleMatch(BuildContext context, WidgetRef ref) async { - final teams = await ref.read(teamsProvider.future); - if (teams.isEmpty) { + final team = await ref.read(activeTeamProvider.future); + if (team == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Nessuna squadra disponibile')), @@ -325,12 +310,12 @@ class MatchesScreen extends ConsumerWidget { return; } - final data = await showScheduleMatchSheet(context); + final data = await showScheduleMatchSheet(context, teamName: team.name); if (data == null || !context.mounted) return; try { final client = ref.read(apiClientProvider); - final match = await client.createMatch(teams.first.id, { + final match = await client.createMatch(team.id, { 'opponent_name': data.opponentName, 'scheduled_at': data.scheduledAt.toUtc().toIso8601String(), if (data.location != null) 'location': data.location, @@ -350,7 +335,7 @@ class MatchesScreen extends ConsumerWidget { backgroundColor: AppTheme.surface, title: const Text('Configurare ora?'), content: const Text( - 'Puoi preparare telefono e regia subito, oppure tornare quando sei in palestra.', + 'Puoi preparare la trasmissione subito, oppure tornare quando sei in palestra.', ), actions: [ TextButton( @@ -378,8 +363,8 @@ class MatchesScreen extends ConsumerWidget { } Future _createMatchQuick(BuildContext context, WidgetRef ref) async { - final teams = await ref.read(teamsProvider.future); - if (teams.isEmpty) { + final team = await ref.read(activeTeamProvider.future); + if (team == null) { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Nessuna squadra disponibile')), @@ -389,7 +374,7 @@ class MatchesScreen extends ConsumerWidget { } final client = ref.read(apiClientProvider); - final match = await client.createMatch(teams.first.id, { + final match = await client.createMatch(team.id, { 'opponent_name': 'Avversario', 'sport': 'volleyball', 'sets_to_win': 3, diff --git a/mobile/lib/features/matches/resume_session_sheet.dart b/mobile/lib/features/matches/resume_session_sheet.dart index 62469eb..e86c6ba 100644 --- a/mobile/lib/features/matches/resume_session_sheet.dart +++ b/mobile/lib/features/matches/resume_session_sheet.dart @@ -3,8 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../app/theme.dart'; -import '../../providers/session_provider.dart'; import '../../shared/models/match.dart'; +import '../../shared/regia_share.dart'; /// Azioni per riprendere una diretta interrotta (crash, chiusura app, ecc.). void showResumeSessionSheet( @@ -51,7 +51,6 @@ void showResumeSessionSheet( if (match.canResumeCamera) FilledButton.icon( onPressed: () { - ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.camera); Navigator.pop(ctx); context.push('/session/$sessionId/camera'); }, @@ -76,12 +75,16 @@ void showResumeSessionSheet( const SizedBox(height: 8), OutlinedButton.icon( onPressed: () { - ref.read(sessionProvider.notifier).setDeviceRole(DeviceRole.controller); - Navigator.pop(ctx); - context.push('/session/$sessionId/controller'); + shareRegiaLink( + ctx, + ref, + sessionId: sessionId, + shareSubject: + 'Regia — ${match.teamName} vs ${match.opponentName}', + ); }, - icon: const Icon(Icons.sports_esports), - label: const Text('Apri regia (punteggio)'), + icon: const Icon(Icons.share), + label: const Text('Condividi link regia'), ), const SizedBox(height: 8), TextButton( diff --git a/mobile/lib/features/matches/schedule_match_sheet.dart b/mobile/lib/features/matches/schedule_match_sheet.dart index fe8b7c4..153c114 100644 --- a/mobile/lib/features/matches/schedule_match_sheet.dart +++ b/mobile/lib/features/matches/schedule_match_sheet.dart @@ -16,7 +16,10 @@ class ScheduleMatchResult { final String? location; } -Future showScheduleMatchSheet(BuildContext context) { +Future showScheduleMatchSheet( + BuildContext context, { + String? teamName, +}) { return showModalBottomSheet( context: context, isScrollControlled: true, @@ -24,12 +27,14 @@ Future showScheduleMatchSheet(BuildContext context) { shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(16)), ), - builder: (ctx) => const _ScheduleMatchSheet(), + builder: (ctx) => _ScheduleMatchSheet(teamName: teamName), ); } class _ScheduleMatchSheet extends StatefulWidget { - const _ScheduleMatchSheet(); + const _ScheduleMatchSheet({this.teamName}); + + final String? teamName; @override State<_ScheduleMatchSheet> createState() => _ScheduleMatchSheetState(); @@ -123,6 +128,16 @@ class _ScheduleMatchSheetState extends State<_ScheduleMatchSheet> { ), const SizedBox(height: 16), Text('Programma partita', style: theme.textTheme.titleLarge), + if (widget.teamName != null && widget.teamName!.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + widget.teamName!, + style: theme.textTheme.titleSmall?.copyWith( + color: AppTheme.primaryRed, + fontWeight: FontWeight.w600, + ), + ), + ], const SizedBox(height: 6), Text( 'La diretta non parte ora: comparirà sul sito con data e ora. ' diff --git a/mobile/lib/features/matches/team_picker.dart b/mobile/lib/features/matches/team_picker.dart new file mode 100644 index 0000000..1426866 --- /dev/null +++ b/mobile/lib/features/matches/team_picker.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/theme.dart'; +import '../../shared/models/team.dart'; +import 'team_providers.dart'; + +/// Selettore squadra quando l'utente ne gestisce più di una (staff trasmissione / società). +class TeamPickerBar extends ConsumerWidget { + const TeamPickerBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final teamsAsync = ref.watch(teamsProvider); + final activeAsync = ref.watch(activeTeamProvider); + + return teamsAsync.when( + loading: () => const SizedBox.shrink(), + error: (_, _) => const SizedBox.shrink(), + data: (teams) { + if (teams.length <= 1) return const SizedBox.shrink(); + + final active = activeAsync.valueOrNull; + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Material( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: () => _showTeamSheet(context, ref, teams, active?.id), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + const Icon(Icons.groups_outlined, color: AppTheme.primaryRed, size: 22), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Squadra per la diretta', + style: theme.textTheme.labelMedium?.copyWith( + color: AppTheme.textSecondary, + ), + ), + Text( + active?.name ?? 'Seleziona squadra', + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + if (active?.clubName != null && active!.clubName!.isNotEmpty) + Text( + active.clubName!, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + const Icon(Icons.expand_more, color: AppTheme.textSecondary), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Future _showTeamSheet( + BuildContext context, + WidgetRef ref, + List teams, + String? selectedId, + ) async { + final picked = await showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 8), + child: Text( + 'Scegli squadra', + style: Theme.of(ctx).textTheme.titleLarge, + ), + ), + ...teams.map((team) { + final selected = team.id == selectedId; + return ListTile( + leading: Icon( + selected ? Icons.check_circle : Icons.circle_outlined, + color: selected ? AppTheme.primaryRed : AppTheme.textSecondary, + ), + title: Text(team.name), + subtitle: team.clubName != null && team.clubName!.isNotEmpty + ? Text(team.clubName!) + : null, + onTap: () => Navigator.pop(ctx, team.id), + ); + }), + const SizedBox(height: 8), + ], + ), + ); + }, + ); + + if (picked == null) return; + await ref.read(selectedTeamIdProvider.notifier).select(picked); + ref.invalidate(matchesProvider); + } +} diff --git a/mobile/lib/features/matches/team_providers.dart b/mobile/lib/features/matches/team_providers.dart index 64ee8dc..260bf2e 100644 --- a/mobile/lib/features/matches/team_providers.dart +++ b/mobile/lib/features/matches/team_providers.dart @@ -1,10 +1,30 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/team_selection_storage.dart'; import '../../providers/auth_provider.dart'; import '../../shared/api_client.dart'; +import '../../shared/models/match.dart'; import '../../shared/models/recording_item.dart'; import '../../shared/models/team.dart'; +final matchesProvider = FutureProvider>((ref) async { + final auth = ref.watch(authProvider); + if (!auth.isAuthenticated) return []; + final team = await ref.watch(activeTeamProvider.future); + if (team == null) return []; + final client = ref.read(apiClientProvider); + final list = await client.fetchMatches(team.id); + list.sort((a, b) { + final sa = a.scheduledAt; + final sb = b.scheduledAt; + if (sa == null && sb == null) return 0; + if (sa == null) return 1; + if (sb == null) return -1; + return sa.compareTo(sb); + }); + return list; +}); + final teamsProvider = FutureProvider>((ref) async { final auth = ref.watch(authProvider); if (!auth.isAuthenticated) return []; @@ -12,12 +32,38 @@ final teamsProvider = FutureProvider>((ref) async { return client.fetchTeams(); }); -final primaryTeamProvider = FutureProvider((ref) async { +final selectedTeamIdProvider = + AsyncNotifierProvider(SelectedTeamIdNotifier.new); + +class SelectedTeamIdNotifier extends AsyncNotifier { + @override + Future build() => TeamSelectionStorage.load(); + + Future select(String teamId) async { + await TeamSelectionStorage.save(teamId); + state = AsyncData(teamId); + } +} + +/// Squadra attiva: preferisce la selezione salvata, altrimenti la prima disponibile. +final activeTeamProvider = FutureProvider((ref) async { final teams = await ref.watch(teamsProvider.future); if (teams.isEmpty) return null; - return teams.first; + + var selectedId = ref.watch(selectedTeamIdProvider).valueOrNull; + final valid = selectedId != null && teams.any((t) => t.id == selectedId); + + if (!valid) { + selectedId = teams.first.id; + await ref.read(selectedTeamIdProvider.notifier).select(selectedId); + } + + return teams.firstWhere((t) => t.id == selectedId); }); +/// Compatibilità con codice esistente. +final primaryTeamProvider = activeTeamProvider; + final teamByIdProvider = FutureProvider.family((ref, teamId) async { final teams = await ref.watch(teamsProvider.future); for (final t in teams) { diff --git a/mobile/lib/features/setup_wizard/step_network_test.dart b/mobile/lib/features/setup_wizard/step_network_test.dart index 736ccda..1870cb6 100644 --- a/mobile/lib/features/setup_wizard/step_network_test.dart +++ b/mobile/lib/features/setup_wizard/step_network_test.dart @@ -15,6 +15,7 @@ import '../../shared/models/match.dart'; import '../../shared/models/score_state.dart'; import '../../shared/models/stream_session.dart'; import '../../shared/websocket_service.dart'; +import '../../shared/regia_share.dart'; import '../../shared/widgets/metric_card.dart'; import '../../shared/widgets/primary_cta.dart'; import '../../shared/widgets/screen_insets.dart'; @@ -111,29 +112,16 @@ class _StepNetworkTestState extends ConsumerState { ref.read(scoreProvider.notifier).reset(const ScoreState()); } - final role = sessionState.deviceRole; - final path = role == DeviceRole.camera - ? '/session/${started.id}/camera' - : '/session/${started.id}/controller'; - if (!mounted) return; - context.go(path); + context.go('/session/${started.id}/camera'); - // WebSocket in background: non bloccare apertura camera/regia try { final ws = ref.read(websocketServiceProvider); - await ws.connect( - sessionId: started.id, - deviceRole: role == DeviceRole.camera ? 'camera' : 'controller', - ); + await ws.connect(sessionId: started.id, deviceRole: 'camera'); } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - 'Diretta avviata ma sync regia limitata: $e', - ), - ), + SnackBar(content: Text('Diretta avviata ma sync limitata: $e')), ); } } @@ -262,6 +250,20 @@ class _StepNetworkTestState extends ConsumerState { style: theme.textTheme.bodyMedium, ), ), + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: ref.read(sessionProvider).session == null + ? null + : () => shareRegiaLink( + context, + ref, + sessionId: ref.read(sessionProvider).session!.id, + shareSubject: + 'Regia — ${widget.match.teamName} vs ${widget.match.opponentName}', + ), + icon: const Icon(Icons.share), + label: const Text('Condividi link regia (punteggio)'), + ), const SizedBox(height: 24), OutlinedButton( onPressed: _testing ? null : _runTest, diff --git a/mobile/lib/features/setup_wizard/step_roles.dart b/mobile/lib/features/setup_wizard/step_roles.dart deleted file mode 100644 index 88c9ec2..0000000 --- a/mobile/lib/features/setup_wizard/step_roles.dart +++ /dev/null @@ -1,238 +0,0 @@ -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:qr_flutter/qr_flutter.dart'; - -import '../../app/theme.dart'; -import '../../providers/session_provider.dart'; -import '../../shared/api_client.dart'; -import '../../shared/models/match.dart'; -import '../../shared/widgets/primary_cta.dart'; -import '../../shared/widgets/screen_insets.dart'; - -class StepRoles extends ConsumerStatefulWidget { - const StepRoles({ - super.key, - required this.match, - required this.onBack, - required this.onNext, - }); - - final MatchModel match; - final VoidCallback onBack; - final VoidCallback onNext; - - @override - ConsumerState createState() => _StepRolesState(); -} - -class _StepRolesState extends ConsumerState { - DeviceRole _role = DeviceRole.controller; - String? _qrData; - bool _loadingQr = false; - - Future _generateQr() async { - final session = ref.read(sessionProvider).session; - if (session == null) return; - - setState(() => _loadingQr = true); - try { - final client = ref.read(apiClientProvider); - final response = await client.createPairingToken(session.id); - setState(() { - _qrData = jsonEncode(response.qrPayload); - }); - } catch (_) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Errore generazione QR')), - ); - } - } finally { - if (mounted) setState(() => _loadingQr = false); - } - } - - void _confirm() { - ref.read(sessionProvider.notifier).setDeviceRole(_role); - widget.onNext(); - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return SingleChildScrollView( - padding: ScreenInsets.contentPadding(context, horizontal: 20, vertical: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Scegli il tuo ruolo', style: theme.textTheme.titleLarge), - const SizedBox(height: 16), - Row( - children: [ - Expanded( - child: _RoleCard( - title: 'CAMERA', - subtitle: 'Sul cavalletto', - icon: Icons.videocam, - selected: _role == DeviceRole.camera, - onTap: () => setState(() => _role = DeviceRole.camera), - ), - ), - const SizedBox(width: 12), - Expanded( - child: _RoleCard( - title: 'REGIA', - subtitle: 'In mano', - icon: Icons.sports_esports, - selected: _role == DeviceRole.controller, - onTap: () => setState(() => _role = DeviceRole.controller), - ), - ), - ], - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppTheme.accentYellow.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(10), - border: Border.all(color: AppTheme.accentYellow.withValues(alpha: 0.4)), - ), - child: Row( - children: [ - const Icon(Icons.battery_charging_full, color: AppTheme.accentYellow), - const SizedBox(width: 12), - Expanded( - child: Text( - 'Mantieni in carica. Saranno 90 minuti a pieno carico.', - style: theme.textTheme.bodyMedium?.copyWith(color: Colors.white), - ), - ), - ], - ), - ), - const SizedBox(height: 24), - Text('Collega il secondo telefono', style: theme.textTheme.titleMedium), - const SizedBox(height: 12), - Center( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - ), - child: _qrData != null - ? QrImageView( - data: _qrData!, - version: QrVersions.auto, - size: 180, - backgroundColor: Colors.white, - ) - : SizedBox( - width: 180, - height: 180, - child: _loadingQr - ? const Center( - child: CircularProgressIndicator( - color: AppTheme.primaryRed, - ), - ) - : Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.qr_code, size: 48, color: Colors.black54), - const SizedBox(height: 8), - TextButton( - onPressed: _generateQr, - child: const Text( - 'GENERA QR', - style: TextStyle(color: Colors.black), - ), - ), - ], - ), - ), - ), - ), - const SizedBox(height: 8), - Text( - 'Scansiona con l\'altro dispositivo per collegare camera e regia.', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: widget.onBack, - child: const Text('Indietro'), - ), - ), - const SizedBox(width: 12), - Expanded( - flex: 2, - child: PrimaryCta( - label: 'AVANTI >', - onPressed: _confirm, - icon: Icons.arrow_forward, - ), - ), - ], - ), - ], - ), - ); - } -} - -class _RoleCard extends StatelessWidget { - const _RoleCard({ - required this.title, - required this.subtitle, - required this.icon, - required this.selected, - required this.onTap, - }); - - final String title; - final String subtitle; - final IconData icon; - final bool selected; - final VoidCallback onTap; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Material( - color: selected - ? AppTheme.primaryRed.withValues(alpha: 0.15) - : AppTheme.surfaceElevated, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: selected ? AppTheme.primaryRed : Colors.transparent, - ), - ), - child: Column( - children: [ - Icon(icon, size: 32, color: selected ? AppTheme.primaryRed : Colors.white), - const SizedBox(height: 8), - Text(title, style: theme.textTheme.titleMedium), - Text(subtitle, style: theme.textTheme.bodyMedium, textAlign: TextAlign.center), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/lib/features/setup_wizard/wizard_shell.dart b/mobile/lib/features/setup_wizard/wizard_shell.dart index 7707b3b..966388d 100644 --- a/mobile/lib/features/setup_wizard/wizard_shell.dart +++ b/mobile/lib/features/setup_wizard/wizard_shell.dart @@ -9,7 +9,6 @@ import '../../shared/api_client.dart'; import '../../shared/widgets/screen_insets.dart'; import 'step_match.dart'; import 'step_network_test.dart'; -import 'step_roles.dart'; import 'step_transmission.dart'; class WizardShell extends ConsumerStatefulWidget { @@ -33,8 +32,7 @@ class _WizardShellState extends ConsumerState { static const _stepTitles = [ '01 · Partita', '02 · Trasmissione', - '03 · Ruoli', - '04 · Test', + '03 · Test rete', ]; @override @@ -74,8 +72,7 @@ class _WizardShellState extends ConsumerState { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final stepIndex = widget.step.clamp(1, 4) - 1; + final stepIndex = widget.step.clamp(1, 3) - 1; return Scaffold( appBar: AppBar( @@ -127,15 +124,9 @@ class _WizardShellState extends ConsumerState { onNext: () => _goToStep(3), ); case 2: - return StepRoles( - match: match, - onBack: () => _goToStep(2), - onNext: () => _goToStep(4), - ); - case 3: return StepNetworkTest( match: match, - onBack: () => _goToStep(3), + onBack: () => _goToStep(2), ); default: return const SizedBox.shrink(); @@ -153,13 +144,13 @@ class _StepIndicator extends StatelessWidget { return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row( - children: List.generate(4, (i) { + children: List.generate(3, (i) { final step = i + 1; final active = step <= current; return Expanded( child: Container( height: 4, - margin: EdgeInsets.only(right: i < 3 ? 6 : 0), + margin: EdgeInsets.only(right: i < 2 ? 6 : 0), decoration: BoxDecoration( color: active ? AppTheme.primaryRed : AppTheme.surfaceElevated, borderRadius: BorderRadius.circular(2), diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 1eb9479..a372b97 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/auth_storage.dart'; +import '../core/team_selection_storage.dart'; import '../shared/models/user.dart'; class AuthState { @@ -82,6 +83,7 @@ class AuthNotifier extends StateNotifier { void logout() { state = const AuthState(); AuthStorage.clear(); + TeamSelectionStorage.clear(); } } diff --git a/mobile/lib/providers/session_provider.dart b/mobile/lib/providers/session_provider.dart index 61373ce..88696aa 100644 --- a/mobile/lib/providers/session_provider.dart +++ b/mobile/lib/providers/session_provider.dart @@ -4,53 +4,38 @@ import '../shared/models/device_state.dart'; import '../shared/models/match.dart'; import '../shared/models/stream_session.dart'; -enum DeviceRole { camera, controller } - class SessionState { const SessionState({ this.session, this.match, - this.connected = false, - this.deviceRole = DeviceRole.controller, - this.cameraDevice, this.elapsed = Duration.zero, + this.connected = false, + this.cameraDevice, this.viewerCount = 0, - this.loading = false, - this.error, }); final StreamSession? session; final MatchModel? match; - final bool connected; - final DeviceRole deviceRole; - final DeviceStateModel? cameraDevice; final Duration elapsed; + final bool connected; + final DeviceStateModel? cameraDevice; final int viewerCount; - final bool loading; - final String? error; SessionState copyWith({ StreamSession? session, MatchModel? match, - bool? connected, - DeviceRole? deviceRole, - DeviceStateModel? cameraDevice, Duration? elapsed, + bool? connected, + DeviceStateModel? cameraDevice, int? viewerCount, - bool? loading, - String? error, - bool clearError = false, }) { return SessionState( session: session ?? this.session, match: match ?? this.match, - connected: connected ?? this.connected, - deviceRole: deviceRole ?? this.deviceRole, - cameraDevice: cameraDevice ?? this.cameraDevice, elapsed: elapsed ?? this.elapsed, + connected: connected ?? this.connected, + cameraDevice: cameraDevice ?? this.cameraDevice, viewerCount: viewerCount ?? this.viewerCount, - loading: loading ?? this.loading, - error: clearError ? null : (error ?? this.error), ); } } @@ -59,23 +44,27 @@ class SessionNotifier extends StateNotifier { SessionNotifier() : super(const SessionState()); void setSession(StreamSession session, {MatchModel? match}) { - state = state.copyWith( - session: session, - match: match, - clearError: true, - ); + state = state.copyWith(session: session, match: match); } void setMatch(MatchModel match) { state = state.copyWith(match: match); } + void setElapsed(Duration elapsed) { + state = state.copyWith(elapsed: elapsed); + } + void setConnected(bool connected) { state = state.copyWith(connected: connected); } - void setDeviceRole(DeviceRole role) { - state = state.copyWith(deviceRole: role); + void setCameraDevice(DeviceStateModel? device) { + state = state.copyWith(cameraDevice: device); + } + + void setViewerCount(int count) { + state = state.copyWith(viewerCount: count); } void updateDeviceState(DeviceStateModel device) { @@ -83,34 +72,29 @@ class SessionNotifier extends StateNotifier { state = state.copyWith(cameraDevice: device); } if (device.status != null && state.session != null) { + final s = state.session!; state = state.copyWith( session: StreamSession( - id: state.session!.id, - matchId: state.session!.matchId, + id: s.id, + matchId: s.matchId, status: device.status!, - platform: state.session!.platform, - rtmpIngestUrl: state.session!.rtmpIngestUrl, - youtubeBroadcastId: state.session!.youtubeBroadcastId, - privacyStatus: state.session!.privacyStatus, - qualityPreset: state.session!.qualityPreset, - targetBitrate: state.session!.targetBitrate, - startedAt: state.session!.startedAt, - disconnectionCount: state.session!.disconnectionCount, - score: state.session!.score, - devices: state.session!.devices, + platform: s.platform, + rtmpIngestUrl: s.rtmpIngestUrl, + hlsPlaybackUrl: s.hlsPlaybackUrl, + watchPageUrl: s.watchPageUrl, + youtubeBroadcastId: s.youtubeBroadcastId, + privacyStatus: s.privacyStatus, + qualityPreset: s.qualityPreset, + targetBitrate: s.targetBitrate, + startedAt: s.startedAt, + disconnectionCount: s.disconnectionCount, + score: s.score, + devices: s.devices, ), ); } } - void setElapsed(Duration elapsed) { - state = state.copyWith(elapsed: elapsed); - } - - void setViewerCount(int count) { - state = state.copyWith(viewerCount: count); - } - void applyCommand(String action) { final session = state.session; if (session == null) return; @@ -128,38 +112,35 @@ class SessionNotifier extends StateNotifier { break; } - state = state.copyWith( - session: StreamSession( - id: session.id, - matchId: session.matchId, - status: newStatus, - platform: session.platform, - rtmpIngestUrl: session.rtmpIngestUrl, - youtubeBroadcastId: session.youtubeBroadcastId, - privacyStatus: session.privacyStatus, - qualityPreset: session.qualityPreset, - targetBitrate: session.targetBitrate, - startedAt: session.startedAt, - disconnectionCount: session.disconnectionCount, - score: session.score, - devices: session.devices, - ), - ); + if (newStatus != session.status) { + state = state.copyWith( + session: StreamSession( + id: session.id, + matchId: session.matchId, + status: newStatus, + platform: session.platform, + rtmpIngestUrl: session.rtmpIngestUrl, + hlsPlaybackUrl: session.hlsPlaybackUrl, + watchPageUrl: session.watchPageUrl, + youtubeBroadcastId: session.youtubeBroadcastId, + privacyStatus: session.privacyStatus, + qualityPreset: session.qualityPreset, + targetBitrate: session.targetBitrate, + startedAt: session.startedAt, + disconnectionCount: session.disconnectionCount, + score: session.score, + devices: session.devices, + ), + ); + } } - void setLoading(bool loading) { - state = state.copyWith(loading: loading); - } - - void setError(String message) { - state = state.copyWith(loading: false, error: message); - } - - void reset() { + void clear() { state = const SessionState(); } } -final sessionProvider = StateNotifierProvider( - (ref) => SessionNotifier(), -); +final sessionProvider = + StateNotifierProvider((ref) { + return SessionNotifier(); +}); diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart index c086bad..da73f9e 100644 --- a/mobile/lib/shared/api_client.dart +++ b/mobile/lib/shared/api_client.dart @@ -176,6 +176,13 @@ class ApiClient { return StreamSession.fromJson(response.data!); } + Future createRegiaLink(String sessionId) async { + final response = await _dio.post>( + '/sessions/$sessionId/regia_link', + ); + return RegiaLinkResponse.fromJson(response.data!); + } + Future createPairingToken(String sessionId) async { final response = await _dio.post>( '/sessions/$sessionId/pairing_token', diff --git a/mobile/lib/shared/models/stream_session.dart b/mobile/lib/shared/models/stream_session.dart index a0c8743..a11322b 100644 --- a/mobile/lib/shared/models/stream_session.dart +++ b/mobile/lib/shared/models/stream_session.dart @@ -74,6 +74,23 @@ class StreamSession { } } +class RegiaLinkResponse { + const RegiaLinkResponse({ + required this.regiaUrl, + required this.expiresAt, + }); + + final String regiaUrl; + final DateTime expiresAt; + + factory RegiaLinkResponse.fromJson(Map json) { + return RegiaLinkResponse( + regiaUrl: json['regia_url'] as String, + expiresAt: DateTime.parse(json['expires_at'] as String), + ); + } +} + class PairingTokenResponse { const PairingTokenResponse({ required this.pairingToken, diff --git a/mobile/lib/shared/models/team.dart b/mobile/lib/shared/models/team.dart index 147f25d..c9e7d13 100644 --- a/mobile/lib/shared/models/team.dart +++ b/mobile/lib/shared/models/team.dart @@ -22,12 +22,18 @@ class Team { this.recordingsEnabled = false, this.phoneDownloadEnabled = false, this.youtubeEnabled = false, + this.clubName, + this.staffRole, + this.canStream = true, this.youtubeMode, }); final String id; final String name; final String sport; + final String? clubName; + final String? staffRole; + final bool canStream; final String? logoUrl; final bool youtubeConnected; final String planSlug; @@ -54,16 +60,10 @@ class Team { bool get canDownloadOnPhone => phoneDownloadEnabled; String get staffLimitLabel { - if (maxStaffTransmission == null && maxStaffRegia == null) { - return 'illimitato'; + if (maxStaffTransmission == null) { + return 'trasmissione illimitata'; } - final tx = maxStaffTransmission == null - ? 'tx ∞' - : 'tx $staffTransmissionUsed/$maxStaffTransmission'; - final rg = maxStaffRegia == null - ? 'rg ∞' - : 'rg $staffRegiaUsed/$maxStaffRegia'; - return '$tx · $rg'; + return 'trasmissione $staffTransmissionUsed/$maxStaffTransmission'; } factory Team.fromJson(Map json) { @@ -71,6 +71,9 @@ class Team { id: json['id'] as String, name: json['name'] as String, sport: json['sport'] as String? ?? 'volleyball', + clubName: json['club_name'] as String?, + staffRole: json['staff_role'] as String?, + canStream: json['can_stream'] as bool? ?? true, logoUrl: json['logo_url'] as String?, youtubeConnected: json['youtube_connected'] as bool? ?? false, planSlug: json['plan_slug'] as String? ?? 'free', diff --git a/mobile/lib/shared/regia_share.dart b/mobile/lib/shared/regia_share.dart new file mode 100644 index 0000000..9e53457 --- /dev/null +++ b/mobile/lib/shared/regia_share.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'api_client.dart'; + +Future shareRegiaLink( + BuildContext context, + WidgetRef ref, { + required String sessionId, + required String shareSubject, +}) async { + try { + final link = await ref.read(apiClientProvider).createRegiaLink(sessionId); + await Share.share( + 'Apri questo link per gestire il punteggio della diretta:\n${link.regiaUrl}', + subject: shareSubject, + ); + } catch (_) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Errore generazione link regia')), + ); + } + } +} diff --git a/mobile/lib/shared/widgets/camera_score_controls.dart b/mobile/lib/shared/widgets/camera_score_controls.dart index 3cbce1e..f5704bc 100644 --- a/mobile/lib/shared/widgets/camera_score_controls.dart +++ b/mobile/lib/shared/widgets/camera_score_controls.dart @@ -1,10 +1,8 @@ import 'package:flutter/material.dart'; -import '../../app/theme.dart'; -import '../../features/controller_mode/controller_screen.dart'; import 'score_overlay_bar.dart'; -/// Controlli punteggio per overlay camera (portrait e landscape). +/// Punteggio in sola lettura sulla camera (la regia avviene via link web). class CameraScoreControls extends StatelessWidget { const CameraScoreControls({ super.key, @@ -15,11 +13,6 @@ class CameraScoreControls extends StatelessWidget { required this.currentSet, required this.homeSets, required this.awaySets, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, this.lastDelta, this.compact = false, this.pointsTarget, @@ -32,85 +25,10 @@ class CameraScoreControls extends StatelessWidget { final int currentSet; final int homeSets; final int awaySets; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; final int? lastDelta; final bool compact; final int? pointsTarget; - @override - Widget build(BuildContext context) { - if (compact) { - return _LandscapeControls( - homeName: homeName, - awayName: awayName, - homePoints: homePoints, - awayPoints: awayPoints, - currentSet: currentSet, - homeSets: homeSets, - awaySets: awaySets, - lastDelta: lastDelta, - pointsTarget: pointsTarget, - onHomePoint: onHomePoint, - onAwayPoint: onAwayPoint, - onHomeMinus: onHomeMinus, - onAwayMinus: onAwayMinus, - onCloseSet: onCloseSet, - ); - } - - return _PortraitControls( - homeName: homeName, - awayName: awayName, - homePoints: homePoints, - awayPoints: awayPoints, - currentSet: currentSet, - homeSets: homeSets, - awaySets: awaySets, - pointsTarget: pointsTarget, - onHomePoint: onHomePoint, - onAwayPoint: onAwayPoint, - onHomeMinus: onHomeMinus, - onAwayMinus: onAwayMinus, - onCloseSet: onCloseSet, - ); - } -} - -class _PortraitControls extends StatelessWidget { - const _PortraitControls({ - required this.homeName, - required this.awayName, - required this.homePoints, - required this.awayPoints, - required this.currentSet, - required this.homeSets, - required this.awaySets, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, - this.pointsTarget, - }); - - final String homeName; - final String awayName; - final int homePoints; - final int awayPoints; - final int currentSet; - final int homeSets; - final int awaySets; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; - final int? pointsTarget; - @override Widget build(BuildContext context) { return Column( @@ -125,179 +43,19 @@ class _PortraitControls extends StatelessWidget { currentSet: currentSet, homeSets: homeSets, awaySets: awaySets, - pointsTarget: pointsTarget, - ), - const SizedBox(height: 10), - Row( - children: [ - Expanded( - child: ScoreTapButton(label: '+1 $homeName', onTap: onHomePoint, large: true), - ), - const SizedBox(width: 8), - Expanded( - child: ScoreTapButton(label: '+1 $awayName', onTap: onAwayPoint, large: true), - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: OutlinedButton( - onPressed: homePoints > 0 ? onHomeMinus : null, - child: Text('-1 $homeName', style: const TextStyle(fontSize: 12)), - ), - ), - const SizedBox(width: 8), - Expanded( - child: OutlinedButton( - onPressed: awayPoints > 0 ? onAwayMinus : null, - child: Text('-1 $awayName', style: const TextStyle(fontSize: 12)), - ), - ), - ], - ), - const SizedBox(height: 8), - YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet), - ], - ); - } -} - -class _LandscapeControls extends StatelessWidget { - const _LandscapeControls({ - required this.homeName, - required this.awayName, - required this.homePoints, - required this.awayPoints, - required this.currentSet, - required this.homeSets, - required this.awaySets, - required this.onHomePoint, - required this.onAwayPoint, - required this.onHomeMinus, - required this.onAwayMinus, - required this.onCloseSet, - this.lastDelta, - this.pointsTarget, - }); - - final String homeName; - final String awayName; - final int homePoints; - final int awayPoints; - final int currentSet; - final int homeSets; - final int awaySets; - final VoidCallback onHomePoint; - final VoidCallback onAwayPoint; - final VoidCallback onHomeMinus; - final VoidCallback onAwayMinus; - final VoidCallback onCloseSet; - final int? lastDelta; - final int? pointsTarget; - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisSize: MainAxisSize.min, - children: [ - ScoreOverlayBar( - homeName: homeName, - awayName: awayName, - homePoints: homePoints, - awayPoints: awayPoints, - currentSet: currentSet, - homeSets: homeSets, - awaySets: awaySets, - compact: true, + compact: compact, lastDelta: lastDelta, pointsTarget: pointsTarget, ), - const SizedBox(height: 8), - Row( - children: [ - Expanded(child: _CompactScoreBtn(label: '+1', sub: homeName, onTap: onHomePoint)), - const SizedBox(width: 6), - Expanded(child: _CompactScoreBtn(label: '+1', sub: awayName, onTap: onAwayPoint)), - const SizedBox(width: 6), - Expanded( - child: _CompactScoreBtn( - label: '-1', - sub: homeName, - onTap: homePoints > 0 ? onHomeMinus : null, - muted: true, - ), - ), - const SizedBox(width: 6), - Expanded( - child: _CompactScoreBtn( - label: '-1', - sub: awayName, - onTap: awayPoints > 0 ? onAwayMinus : null, - muted: true, - ), - ), - ], - ), - const SizedBox(height: 6), - YellowActionButton(label: 'Chiudi set', onPressed: onCloseSet), + if (!compact) ...[ + const SizedBox(height: 8), + Text( + 'Punteggio: condividi il link regia con chi gestisce il tabellone.', + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.white54), + textAlign: TextAlign.center, + ), + ], ], ); } } - -class _CompactScoreBtn extends StatelessWidget { - const _CompactScoreBtn({ - required this.label, - required this.sub, - required this.onTap, - this.muted = false, - }); - - final String label; - final String sub; - final VoidCallback? onTap; - final bool muted; - - @override - Widget build(BuildContext context) { - final enabled = onTap != null; - return Material( - color: muted - ? Colors.black.withValues(alpha: enabled ? 0.65 : 0.35) - : AppTheme.primaryRed.withValues(alpha: enabled ? 0.92 : 0.45), - borderRadius: BorderRadius.circular(10), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(10), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - label, - style: TextStyle( - color: enabled ? Colors.white : Colors.white38, - fontWeight: FontWeight.w900, - fontSize: 18, - ), - ), - Text( - sub, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: enabled ? Colors.white70 : Colors.white30, - fontSize: 9, - ), - ), - ], - ), - ), - ), - ); - } -} diff --git a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift index 6d56bd2..7a7aff1 100644 --- a/mobile/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/mobile/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,6 +9,7 @@ import battery_plus import connectivity_plus import mobile_scanner import package_info_plus +import share_plus import shared_preferences_foundation import url_launcher_macos import wakelock_plus @@ -18,6 +19,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin")) diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 81bc94c..22c4bf6 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: match_live_tv description: Match Live TV - Lo streaming che non muore publish_to: 'none' -version: 1.0.0+1 +version: 1.1.0+2 environment: sdk: ^3.12.0 @@ -24,6 +24,7 @@ dependencies: mobile_scanner: ^6.0.10 shared_preferences: ^2.5.3 url_launcher: ^6.3.1 + share_plus: ^10.1.4 dev_dependencies: flutter_test: diff --git a/mobile/windows/flutter/generated_plugin_registrant.cc b/mobile/windows/flutter/generated_plugin_registrant.cc index bfc9598..4443ebc 100644 --- a/mobile/windows/flutter/generated_plugin_registrant.cc +++ b/mobile/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void RegisterPlugins(flutter::PluginRegistry* registry) { @@ -18,6 +19,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("UrlLauncherWindows")); } diff --git a/mobile/windows/flutter/generated_plugins.cmake b/mobile/windows/flutter/generated_plugins.cmake index d60dff0..4a8c71a 100644 --- a/mobile/windows/flutter/generated_plugins.cmake +++ b/mobile/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST battery_plus connectivity_plus permission_handler_windows + share_plus url_launcher_windows ) diff --git a/scripts/run_mobile_android_prod.sh b/scripts/run_mobile_android_prod.sh index 4892113..31f1f9e 100755 --- a/scripts/run_mobile_android_prod.sh +++ b/scripts/run_mobile_android_prod.sh @@ -13,7 +13,7 @@ if [[ -z "$DEVICE" ]]; then exit 1 fi -API_BASE_URL="${API_BASE_URL:-https://matchlivetv.eminux.it}" +API_BASE_URL="${API_BASE_URL:-https://www.matchlivetv.it}" MODE="${BUILD_MODE:-debug}" cd "$(dirname "$0")/../mobile" diff --git a/scripts/test_billing_flow.sh b/scripts/test_billing_flow.sh new file mode 100755 index 0000000..5ed7cd0 --- /dev/null +++ b/scripts/test_billing_flow.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Test integrazione billing (senza emissione fatture Aruba API) +set -euo pipefail +BASE="${BASE_URL:-http://localhost:3000}" +COOKIES=$(mktemp) +trap 'rm -f "$COOKIES"' EXIT + +pass=0 +fail=0 +ok() { echo "OK $1"; pass=$((pass+1)); } +ko() { echo "FAIL $1"; fail=$((fail+1)); } + +login() { + curl -sf -c "$COOKIES" "$BASE/login" -o /tmp/login.html + T=$(grep -oP 'name="authenticity_token" value="\K[^"]+' /tmp/login.html | head -1) + curl -sf -c "$COOKIES" -b "$COOKIES" -X POST "$BASE/login" \ + -d "authenticity_token=$T" \ + -d "email=coach@matchlivetv.test" \ + -d "password=password123" -o /dev/null +} + +login +CLUB_ID=$(docker compose -f "$(dirname "$0")/../infra/docker-compose.yml" exec -T rails bundle exec rails runner "puts Club.first.id" 2>/dev/null | tail -1) + +# Health +curl -sf "$BASE/up" >/dev/null && ok "/up" || ko "/up" + +# Billing page +BODY=$(curl -sf -b "$COOKIES" "$BASE/clubs/$CLUB_ID/billing") +echo "$BODY" | grep -q "Pagamenti effettuati" && ok "billing: sezione pagamenti" || ko "billing: sezione pagamenti" +echo "$BODY" | grep -q ">Fatture/dev/null 2>&1 +CODE=$(curl -s -o /dev/null -w "%{http_code}" -L -b "$COOKIES" "$BASE/clubs/$CLUB_ID/checkout?plan=premium_light") +[[ "$CODE" == "200" ]] && ok "checkout redirect senza dati fiscali" || ko "checkout senza dati fiscali ($CODE)" + +# Record payment via runner +docker compose -f "$(dirname "$0")/../infra/docker-compose.yml" exec -T rails bundle exec rails runner " +require 'ostruct' +club = Club.first +inv = OpenStruct.new( + id: 'in_integration_test', + subscription: club.subscription&.stripe_subscription_id, + metadata: { 'club_id' => club.id }, + amount_paid: 4000, + currency: 'eur', + status: 'paid', + description: 'Test', + payment_intent: 'pi_t', + charge: 'ch_t', + invoice_pdf: nil, + hosted_invoice_url: 'https://stripe.com/test', + status_transitions: OpenStruct.new(paid_at: Time.current.to_i), + lines: OpenStruct.new(data: [OpenStruct.new(description: 'Premium Light')]), + subscription_details: nil +) +Billing::Stripe::RecordPayment.call(stripe_invoice: inv, club: club) +puts Billing::Payment.last.stripe_invoice_id +" >/dev/null 2>&1 && ok "RecordPayment runner" || ko "RecordPayment runner" + +BODY2=$(curl -sf -b "$COOKIES" "$BASE/clubs/$CLUB_ID/billing") +echo "$BODY2" | grep -q "in_integration_test\|Premium Light\|40,00" && ok "pagamento visibile in billing" || ko "pagamento visibile in billing" + +# Webhook invoice.paid (mock event structure) +docker compose -f "$(dirname "$0")/../infra/docker-compose.yml" exec -T rails bundle exec rails runner " +require 'ostruct' +club = Club.first +event = OpenStruct.new( + type: 'invoice.paid', + data: OpenStruct.new(object: OpenStruct.new( + id: \"in_webhook_test_#{Time.now.to_i}\", + subscription: club.subscription&.stripe_subscription_id, + metadata: { 'club_id' => club.id }, + amount_paid: 5000, + currency: 'eur', + status: 'paid', + description: 'Webhook test', + payment_intent: 'pi_w', + charge: 'ch_w', + invoice_pdf: nil, + hosted_invoice_url: nil, + status_transitions: OpenStruct.new(paid_at: Time.current.to_i), + lines: OpenStruct.new(data: []), + subscription_details: nil + )) +) +Billing::Stripe::WebhookHandler.call(event) +" >/dev/null 2>&1 && ok "WebhookHandler invoice.paid" || ko "WebhookHandler invoice.paid" + +# Draft invoice (no PDF) - admin-side model only +docker compose -f "$(dirname "$0")/../infra/docker-compose.yml" exec -T rails bundle exec rails runner " +club = Club.first +club.billing_invoices.find_or_create_by!(number: 'TEST/2026') do |i| + i.issued_on = Date.current + i.amount_cents = 4000 + i.status = 'draft' +end +" >/dev/null 2>&1 && ok "fattura draft creata" || ko "fattura draft creata" + +BODY3=$(curl -sf -b "$COOKIES" "$BASE/clubs/$CLUB_ID/billing") +echo "$BODY3" | grep -q "TEST/2026" && ok "fattura draft in elenco" || ko "fattura draft in elenco" + +echo "" +echo "Risultato: $pass OK, $fail FAIL" +[[ "$fail" -eq 0 ]]