From 566104aff4944f5f49508a275953d7e057ff23d6 Mon Sep 17 00:00:00 2001 From: Emiliano Frascaro Date: Tue, 2 Jun 2026 22:15:27 +0200 Subject: [PATCH] Billing upgrade/downgrade, inviti in app e diretta YouTube da mobile. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upgrade Stripe con addebito immediato; downgrade programmato a fine periodo. UX billing più sobria con box info; icone piani. API inviti e OAuth YouTube per staff trasmissione; deep link join; scelta partita programmata o nuova. Co-authored-by: Cursor --- .../api/v1/invitations_controller.rb | 52 +++++ .../controllers/api/v1/teams_controller.rb | 3 +- .../controllers/api/v1/youtube_controller.rb | 13 +- .../controllers/public/clubs_controller.rb | 19 +- .../controllers/public/teams_controller.rb | 16 +- .../youtube_oauth_callback_controller.rb | 13 +- backend/app/helpers/application_helper.rb | 10 + backend/app/helpers/public/billing_helper.rb | 47 ++-- backend/app/models/subscription.rb | 5 + .../billing/stripe/apply_pending_plan.rb | 76 +++++++ .../services/billing/stripe/change_plan.rb | 74 +++++- .../billing/stripe/change_plan_result.rb | 22 ++ .../services/billing/stripe/checkout_sync.rb | 9 +- .../billing/stripe/plan_change_kind.rb | 33 +++ .../billing/stripe/plan_change_messages.rb | 60 +++++ .../billing/stripe/schedule_downgrade.rb | 80 +++++++ .../billing/stripe/webhook_handler.rb | 6 +- backend/app/services/youtube/oauth_state.rb | 15 +- .../app/views/public/clubs/billing.html.erb | 3 + .../views/public/invitations/show.html.erb | 7 + .../app/views/public/teams/billing.html.erb | 3 +- .../shared/_club_subscription_status.html.erb | 12 + backend/app/views/shared/_plan_cards.html.erb | 13 +- .../views/shared/_plan_change_info.html.erb | 17 ++ backend/app/views/shared/_plan_title.html.erb | 5 + .../shared/_subscription_cancel.html.erb | 2 +- backend/config/routes.rb | 3 + ...20000_add_pending_plan_to_subscriptions.rb | 9 + backend/db/schema.rb | 7 +- backend/public/marketing.css | 82 ++++++- .../helpers/public/billing_helper_spec.rb | 9 + .../spec/requests/api/v1/invitations_spec.rb | 54 +++++ .../billing/stripe/change_plan_spec.rb | 59 +++-- .../billing/stripe/plan_change_kind_spec.rb | 32 +++ docs/APP_YOUTUBE_LIVE.md | 59 +++++ .../android/app/src/main/AndroidManifest.xml | 12 + mobile/lib/app/router.dart | 4 +- mobile/lib/core/deep_link_listener.dart | 77 +++++++ mobile/lib/core/invite_storage.dart | 20 ++ mobile/lib/features/auth/login_screen.dart | 214 +++++++++++++----- .../lib/features/matches/matches_screen.dart | 131 ++++++++--- .../lib/features/matches/new_match_sheet.dart | 105 +++++++++ .../features/matches/select_match_sheet.dart | 185 +++++++++++++++ .../setup_wizard/step_transmission.dart | 45 +++- mobile/lib/main.dart | 13 +- mobile/lib/shared/api_client.dart | 19 ++ mobile/lib/shared/models/invite_preview.dart | 28 +++ mobile/lib/shared/models/team.dart | 3 + mobile/pubspec.yaml | 3 +- 49 files changed, 1615 insertions(+), 173 deletions(-) create mode 100644 backend/app/controllers/api/v1/invitations_controller.rb create mode 100644 backend/app/services/billing/stripe/apply_pending_plan.rb create mode 100644 backend/app/services/billing/stripe/change_plan_result.rb create mode 100644 backend/app/services/billing/stripe/plan_change_kind.rb create mode 100644 backend/app/services/billing/stripe/plan_change_messages.rb create mode 100644 backend/app/services/billing/stripe/schedule_downgrade.rb create mode 100644 backend/app/views/shared/_plan_change_info.html.erb create mode 100644 backend/app/views/shared/_plan_title.html.erb create mode 100644 backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb create mode 100644 backend/spec/requests/api/v1/invitations_spec.rb create mode 100644 backend/spec/services/billing/stripe/plan_change_kind_spec.rb create mode 100644 docs/APP_YOUTUBE_LIVE.md create mode 100644 mobile/lib/core/deep_link_listener.dart create mode 100644 mobile/lib/core/invite_storage.dart create mode 100644 mobile/lib/features/matches/new_match_sheet.dart create mode 100644 mobile/lib/features/matches/select_match_sheet.dart create mode 100644 mobile/lib/shared/models/invite_preview.dart diff --git a/backend/app/controllers/api/v1/invitations_controller.rb b/backend/app/controllers/api/v1/invitations_controller.rb new file mode 100644 index 0000000..a58d967 --- /dev/null +++ b/backend/app/controllers/api/v1/invitations_controller.rb @@ -0,0 +1,52 @@ +module Api + module V1 + class InvitationsController < BaseController + skip_before_action :authenticate_request!, only: :show + + def show + invitation = find_pending_invitation + unless invitation + return render json: { valid: false, error: "Invito non valido o scaduto" }, status: :not_found + end + + render json: { + valid: true, + email: invitation.email, + team_id: invitation.team_id, + team_name: invitation.team.name, + club_name: invitation.team.club.name, + staff_kind: invitation.staff_kind, + expires_at: invitation.expires_at + } + end + + def accept + invitation = find_pending_invitation + return render json: { error: "Invito non valido o scaduto" }, status: :not_found unless invitation + + if current_user.email.downcase != invitation.email.downcase + return render json: { + error: "Questo invito è per #{invitation.email}. Accedi con quell'indirizzo email.", + invited_email: invitation.email + }, status: :unprocessable_entity + end + + invitation.accept!(current_user) + render json: { + team_id: invitation.team_id, + team_name: invitation.team.name, + message: "Sei entrato in #{invitation.team.name} come responsabile trasmissione." + } + end + + private + + def find_pending_invitation + token = params[:token].to_s + return nil if token.blank? + + TeamInvitation.pending.find_by(token_digest: Digest::SHA256.hexdigest(token)) + end + end + end +end diff --git a/backend/app/controllers/api/v1/teams_controller.rb b/backend/app/controllers/api/v1/teams_controller.rb index 22c445f..e6ec7f0 100644 --- a/backend/app/controllers/api/v1/teams_controller.rb +++ b/backend/app/controllers/api/v1/teams_controller.rb @@ -103,7 +103,8 @@ module Api youtube_enabled: ent.youtube_enabled?, youtube_mode: ent.plan.youtube_mode, staff_role: current_user.staff_role_for(team), - can_stream: current_user.can_stream_for?(team) + can_stream: current_user.can_stream_for?(team), + can_connect_youtube: current_user.can_stream_for?(team) && ent.premium_full? && ent.youtube_enabled? } if detail data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut| diff --git a/backend/app/controllers/api/v1/youtube_controller.rb b/backend/app/controllers/api/v1/youtube_controller.rb index 5b14107..4f3d4f0 100644 --- a/backend/app/controllers/api/v1/youtube_controller.rb +++ b/backend/app/controllers/api/v1/youtube_controller.rb @@ -2,11 +2,18 @@ module Api module V1 class YoutubeController < BaseController def authorize - team = current_user.manageable_teams.find(params[:team_id]) + team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s } + raise ActiveRecord::RecordNotFound unless team + team.entitlements.assert_can_connect_youtube! - state = Youtube::OauthState.for_team(team_id: team.id, user_id: current_user.id) + return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app]) + state = Youtube::OauthState.for_team( + team_id: team.id, + user_id: current_user.id, + return_app: return_app + ) url = Youtube::OauthUrl.build(state: state, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI")) - render json: { authorization_url: url } + render json: { authorization_url: url, return_app: return_app } end end end diff --git a/backend/app/controllers/public/clubs_controller.rb b/backend/app/controllers/public/clubs_controller.rb index f0f5508..7f29262 100644 --- a/backend/app/controllers/public/clubs_controller.rb +++ b/backend/app/controllers/public/clubs_controller.rb @@ -96,16 +96,27 @@ module Public sub = @club.subscription if sub&.stripe_subscription_id.present? && sub.active? && - sub.plan.slug == plan_slug && sub.billing_interval == interval + sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending? redirect_to public_club_billing_path(@club), notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})." return end - if sub&.stripe_subscription_id.present? && sub.active? - Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval) + if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug && + sub.pending_billing_interval == interval + when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : "il prossimo rinnovo" redirect_to public_club_billing_path(@club), - notice: "Piano aggiornato a #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)}). Eventuale differenza gestita da Stripe." + notice: "Il passaggio a #{target_plan.name} è già programmato dal #{when_label}." + return + end + + if sub&.stripe_subscription_id.present? && sub.active? + result = Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval) + redirect_to public_club_billing_path(@club), + notice: Billing::Stripe::PlanChangeMessages.flash_notice( + result, + current_plan: sub.plan + ) else url = Billing::Stripe::CheckoutSession.new( club: @club, user: current_user, plan_slug: plan_slug, interval: interval diff --git a/backend/app/controllers/public/teams_controller.rb b/backend/app/controllers/public/teams_controller.rb index 3fcaae1..7a2fdd0 100644 --- a/backend/app/controllers/public/teams_controller.rb +++ b/backend/app/controllers/public/teams_controller.rb @@ -116,7 +116,7 @@ module Public end def youtube_connect - require_club_owner_for_team!(@team) + require_youtube_connect_access!(@team) @team.entitlements.assert_can_connect_youtube! if ENV["YOUTUBE_CLIENT_ID"].blank? @@ -124,7 +124,12 @@ module Public return end - state = Youtube::OauthState.for_team(team_id: @team.id, user_id: current_user.id) + return_app = params[:return_app] == "1" + state = Youtube::OauthState.for_team( + team_id: @team.id, + user_id: current_user.id, + return_app: return_app + ) redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true rescue Teams::EntitlementError => e redirect_to public_team_details_path(@team), alert: e.message @@ -138,6 +143,13 @@ module Public private + def require_youtube_connect_access!(team) + return if team.club&.owned_by?(current_user) + return if current_user.can_stream_for?(team) + + redirect_to public_team_details_path(team), alert: "Accesso non consentito" + end + def load_team_details! @club = @team.club @can_manage = @club.owned_by?(current_user) diff --git a/backend/app/controllers/youtube_oauth_callback_controller.rb b/backend/app/controllers/youtube_oauth_callback_controller.rb index 69e8a33..83cc28d 100644 --- a/backend/app/controllers/youtube_oauth_callback_controller.rb +++ b/backend/app/controllers/youtube_oauth_callback_controller.rb @@ -13,7 +13,11 @@ class YoutubeOauthCallbackController < ActionController::Base end user = User.find(ctx[:user_id]) - team = user.manageable_teams.find(ctx[:team_id]) + team = Team.find(ctx[:team_id]) + unless user.can_stream_for?(team) || team.club&.owned_by?(user) + return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra") + end + cred = team.youtube_credential || team.build_youtube_credential cred.update!( access_token: tokens[:access_token], @@ -22,7 +26,12 @@ class YoutubeOauthCallbackController < ActionController::Base channel_id: tokens[:channel_id], channel_title: tokens[:channel_title] ) - redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true + + if ctx[:return_app] + redirect_to "matchlivetv://youtube-connected?team_id=#{team.id}", allow_other_host: true + else + redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true + end rescue ArgumentError, Youtube::BroadcastService::Error => e redirect_to_oauth_error(e.message) end diff --git a/backend/app/helpers/application_helper.rb b/backend/app/helpers/application_helper.rb index c587773..be90c6c 100644 --- a/backend/app/helpers/application_helper.rb +++ b/backend/app/helpers/application_helper.rb @@ -1,4 +1,14 @@ module ApplicationHelper + PLAN_ICONS = { + "free" => "fa-solid fa-seedling", + "premium_light" => "fa-solid fa-rocket", + "premium_full" => "fa-solid fa-crown" + }.freeze + + def plan_icon_class(plan) + PLAN_ICONS[plan.slug] || "fa-solid fa-circle" + end + # Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito. def l_local(date_or_time, format: :long) return nil if date_or_time.blank? diff --git a/backend/app/helpers/public/billing_helper.rb b/backend/app/helpers/public/billing_helper.rb index 5a70dcb..96e1479 100644 --- a/backend/app/helpers/public/billing_helper.rb +++ b/backend/app/helpers/public/billing_helper.rb @@ -1,10 +1,11 @@ module Public module BillingHelper - def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil) + def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil) if target_plan.slug == "free" - return { kind: :contact, label: "Contatta il supporto per downgrade." } unless current_slug == "free" + return { kind: :current, label: "Piano attuale" } if current_slug == "free" + return { kind: :none } if stripe_subscription_active - return { kind: :current, label: "Piano attuale" } + return { kind: :contact, label: "Per passare al piano Free, contatta il supporto." } end unless MatchLiveTv.stripe_enabled? @@ -16,20 +17,34 @@ module Public active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL - if current_slug == target_plan.slug && stripe_subscription_active + if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending? other_intervals = intervals - [active_interval] if other_intervals.empty? label = "Piano attuale — #{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}" return { kind: :current, label: label } end - return { kind: :interval_switch, plan: target_plan, intervals: other_intervals, current_interval: active_interval } + return { + kind: :interval_switch, + plan: target_plan, + intervals: other_intervals, + current_interval: active_interval, + current_slug: current_slug, + subscription: subscription + } end if current_slug == "free" || !stripe_subscription_active - { kind: :checkout_options, plan: target_plan, intervals: intervals } + { kind: :checkout_options, plan: target_plan, intervals: intervals, subscription: subscription } else - { kind: :change_options, plan: target_plan, intervals: intervals } + { + kind: :change_options, + plan: target_plan, + intervals: intervals, + current_slug: current_slug, + current_interval: active_interval, + subscription: subscription + } end end @@ -37,14 +52,13 @@ module Public public_club_checkout_path(club, plan: plan.slug, interval: interval) end - def plan_interval_button_label(plan, interval, kind: :checkout) - price = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval) - case kind - when :change, :interval_switch - "Passa a #{plan.name} — #{price}" - else - "Attiva #{plan.name} — #{price}" - end + def plan_interval_button_label(plan, interval, kind: :checkout, **) + btn_kind = kind == :checkout_options ? :checkout : :change + Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind) + end + + def billing_plan_change_info_lines(subscription: nil) + Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription) end def stripe_subscription_active?(subscription) @@ -55,7 +69,7 @@ module Public Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval) end - def plan_interval_button_class(interval) + def plan_interval_button_class(interval, **) if interval.to_s == "yearly" "btn btn-primary plan-interval-btn plan-interval-btn--yearly" else @@ -77,5 +91,6 @@ module Public "Mancano: #{missing.join(", ")}." end + end end diff --git a/backend/app/models/subscription.rb b/backend/app/models/subscription.rb index f853356..22622aa 100644 --- a/backend/app/models/subscription.rb +++ b/backend/app/models/subscription.rb @@ -4,6 +4,7 @@ class Subscription < ApplicationRecord belongs_to :club belongs_to :plan + belongs_to :pending_plan, class_name: "Plan", optional: true validates :status, inclusion: { in: STATUSES } validates :club_id, uniqueness: true @@ -25,4 +26,8 @@ class Subscription < ApplicationRecord def free? plan.slug == "free" end + + def plan_change_pending? + pending_plan_id.present? + end end diff --git a/backend/app/services/billing/stripe/apply_pending_plan.rb b/backend/app/services/billing/stripe/apply_pending_plan.rb new file mode 100644 index 0000000..dd3cdff --- /dev/null +++ b/backend/app/services/billing/stripe/apply_pending_plan.rb @@ -0,0 +1,76 @@ +module Billing + module Stripe + # Allinea piano locale quando scatta un downgrade programmato (o dopo sync Stripe). + class ApplyPendingPlan + def self.call(club:) + new(club: club).call + end + + def initialize(club:) + @club = club + @sub = club.subscription + end + + def call + return false unless @sub&.pending_plan_id.present? + + pending_slug = @sub.pending_plan.slug + interval = @sub.pending_billing_interval.presence || @sub.billing_interval + + if @sub.stripe_subscription_id.present? + stripe_sub = ::Stripe::Subscription.retrieve(@sub.stripe_subscription_id) + current_price_id = stripe_sub.items.data.first.price.id + expected_price_id = PriceCatalog.price_id(plan_slug: pending_slug, interval: interval) + return false unless current_price_id == expected_price_id + + inferred = PriceCatalog.infer_from_price_id(current_price_id) + if inferred + pending_slug, interval = inferred + end + period_start, period_end = SubscriptionPeriod.times(stripe_sub) + Billing::AssignPlan.call( + club: @club, + plan_slug: pending_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, + billing_interval: interval, + pending_plan_id: nil, + pending_billing_interval: nil, + stripe_schedule_id: nil + } + ) + else + Billing::AssignPlan.call( + club: @club, + plan_slug: pending_slug, + stripe_attrs: { + billing_interval: interval, + pending_plan_id: nil, + pending_billing_interval: nil, + stripe_schedule_id: nil + } + ) + end + + true + end + + private + + 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/change_plan.rb b/backend/app/services/billing/stripe/change_plan.rb index bdfc118..ff8ea30 100644 --- a/backend/app/services/billing/stripe/change_plan.rb +++ b/backend/app/services/billing/stripe/change_plan.rb @@ -11,37 +11,88 @@ module Billing @club = club @plan_slug = plan_slug.to_s @interval = interval + @sub = club.subscription 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 "Nessun abbonamento Stripe attivo" if @sub.blank? || @sub.stripe_subscription_id.blank? - interval = resolve_interval(sub) - if sub.plan.slug == @plan_slug && sub.billing_interval == interval + interval = resolve_interval(@sub) + if @sub.plan.slug == @plan_slug && @sub.billing_interval == interval && !@sub.pending_plan_id? raise "Sei già su questo piano e intervallo di fatturazione" end - price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: interval) + kind = PlanChangeKind.classify( + from_plan_slug: @sub.plan.slug, + from_interval: @sub.billing_interval, + to_plan_slug: @plan_slug, + to_interval: interval + ) - stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id) + case kind + when :upgrade + perform_upgrade!(interval: interval) + when :downgrade + ScheduleDowngrade.call(club: @club, plan_slug: @plan_slug, interval: interval) + else + raise "Sei già su questo piano e intervallo di fatturazione" + end + end + + private + + def perform_upgrade!(interval:) + release_schedule_if_any! + + price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: interval) + 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, billing_interval: interval }, - proration_behavior: "create_prorations" + proration_behavior: "always_invoice", + payment_behavior: "error_if_incomplete" ) + invoice = pay_open_subscription_invoice!(updated.id) apply_local_plan!(updated, interval: interval) - updated + + ChangePlanResult.new( + kind: :upgrade, + plan_slug: @plan_slug, + interval: interval, + effective_at: Time.current, + amount_cents: invoice&.amount_paid, + currency: invoice&.currency, + invoice_id: invoice&.id + ) end - private + def release_schedule_if_any! + return if @sub.stripe_schedule_id.blank? + + ::Stripe::SubscriptionSchedule.release(@sub.stripe_schedule_id) + rescue ::Stripe::InvalidRequestError + nil + ensure + @sub.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil) + end + + def pay_open_subscription_invoice!(stripe_sub_id) + invoice = ::Stripe::Invoice.list(subscription: stripe_sub_id, status: "open", limit: 1).data.first + return nil unless invoice + + paid = invoice.status == "paid" ? invoice : ::Stripe::Invoice.pay(invoice.id) + RecordPayment.call(stripe_invoice: paid, club: @club) if paid.status == "paid" + paid + rescue ::Stripe::CardError => e + raise ::Stripe::CardError, "Pagamento non riuscito: #{e.message}" + end def resolve_interval(sub) return PriceCatalog.normalize_interval(@interval) if @interval.present? @@ -61,7 +112,10 @@ module Billing current_period_start: period_start, current_period_end: period_end, cancel_at_period_end: stripe_sub.cancel_at_period_end, - billing_interval: interval + billing_interval: interval, + pending_plan_id: nil, + pending_billing_interval: nil, + stripe_schedule_id: nil } ) end diff --git a/backend/app/services/billing/stripe/change_plan_result.rb b/backend/app/services/billing/stripe/change_plan_result.rb new file mode 100644 index 0000000..b5e0d62 --- /dev/null +++ b/backend/app/services/billing/stripe/change_plan_result.rb @@ -0,0 +1,22 @@ +module Billing + module Stripe + ChangePlanResult = Struct.new( + :kind, + :plan_slug, + :interval, + :effective_at, + :amount_cents, + :currency, + :invoice_id, + keyword_init: true + ) do + def upgrade? + kind == :upgrade + end + + def downgrade? + kind == :downgrade + end + end + end +end diff --git a/backend/app/services/billing/stripe/checkout_sync.rb b/backend/app/services/billing/stripe/checkout_sync.rb index b444eae..684daa4 100644 --- a/backend/app/services/billing/stripe/checkout_sync.rb +++ b/backend/app/services/billing/stripe/checkout_sync.rb @@ -69,6 +69,10 @@ module Billing infer_interval_from_price(stripe_sub) || PriceCatalog::DEFAULT_INTERVAL + sub = club.subscription + clear_pending = sub&.pending_plan_id.present? && + (sub.pending_plan.slug == plan.slug || infer_plan_slug_from_price(stripe_sub) == sub.pending_plan.slug) + Billing::AssignPlan.call( club: club, plan_slug: plan.slug, @@ -79,7 +83,10 @@ module Billing current_period_start: period_start, current_period_end: period_end, cancel_at_period_end: stripe_sub.cancel_at_period_end, - billing_interval: billing_interval + billing_interval: billing_interval, + pending_plan_id: clear_pending ? nil : sub&.pending_plan_id, + pending_billing_interval: clear_pending ? nil : sub&.pending_billing_interval, + stripe_schedule_id: clear_pending ? nil : sub&.stripe_schedule_id } ) end diff --git a/backend/app/services/billing/stripe/plan_change_kind.rb b/backend/app/services/billing/stripe/plan_change_kind.rb new file mode 100644 index 0000000..d1a0e48 --- /dev/null +++ b/backend/app/services/billing/stripe/plan_change_kind.rb @@ -0,0 +1,33 @@ +module Billing + module Stripe + module PlanChangeKind + module_function + + def classify(from_plan_slug:, from_interval:, to_plan_slug:, to_interval:) + from_tier = Plan.tier(from_plan_slug) + to_tier = Plan.tier(to_plan_slug) + from_interval = PriceCatalog.normalize_interval(from_interval) + to_interval = PriceCatalog.normalize_interval(to_interval) + + return :upgrade if to_tier > from_tier + return :downgrade if to_tier < from_tier + + if from_interval == to_interval + :same + elsif to_interval == "yearly" + :upgrade + else + :downgrade + end + end + + def upgrade?(**kwargs) + classify(**kwargs) == :upgrade + end + + def downgrade?(**kwargs) + classify(**kwargs) == :downgrade + end + end + end +end diff --git a/backend/app/services/billing/stripe/plan_change_messages.rb b/backend/app/services/billing/stripe/plan_change_messages.rb new file mode 100644 index 0000000..ffb8c35 --- /dev/null +++ b/backend/app/services/billing/stripe/plan_change_messages.rb @@ -0,0 +1,60 @@ +module Billing + module Stripe + module PlanChangeMessages + module_function + + def flash_notice(result, current_plan: nil) + plan = Plan[result.plan_slug] + interval_label = PriceCatalog.label(plan_slug: result.plan_slug, interval: result.interval) + + if result.upgrade? + amount = format_amount(result.amount_cents, result.currency) + if amount.present? + "Upgrade a #{plan.name} (#{interval_label}) completato. Addebito immediato: #{amount}." + else + "Upgrade a #{plan.name} (#{interval_label}) completato. L'addebito della differenza è in elaborazione sulla carta salvata." + end + else + when_label = result.effective_at.present? ? I18n.l(result.effective_at, format: :long) : "il prossimo rinnovo" + current_name = current_plan&.name || "il piano attuale" + "Passaggio a #{plan.name} (#{interval_label}) programmato: resti su #{current_name} fino al #{when_label}. " \ + "Nessun addebito aggiuntivo ora; dal prossimo ciclo pagherai il nuovo prezzo." + end + end + + def button_label(plan:, interval:, kind:) + price = PriceCatalog.label(plan_slug: plan.slug, interval: interval) + if kind == :checkout + "Attiva #{plan.name} — #{price}" + else + "Passa a #{plan.name} — #{price}" + end + end + + def billing_info_lines(subscription: nil) + lines = [ + "Upgrade (piano superiore o passaggio alla fatturazione annuale): la differenza viene addebitata subito sulla carta salvata in Stripe.", + "Downgrade tra piani a pagamento (es. Full → Light): il nuovo piano è attivo dal prossimo rinnovo; fino ad allora restano le funzioni del piano attuale, senza addebito aggiuntivo ora." + ] + + if subscription&.plan_change_pending? && subscription.pending_plan.present? + when_at = subscription.current_period_end + when_label = when_at.present? ? I18n.l(when_at, format: :long) : "il prossimo rinnovo" + lines.unshift( + "Hai già richiesto #{subscription.pending_plan.name} dal #{when_label}: fino ad allora resta attivo #{subscription.plan.name}." + ) + end + + lines + end + + def format_amount(cents, currency) + return nil if cents.blank? || cents.to_i <= 0 + + unit = currency.to_s.upcase.presence || "EUR" + amount = cents.to_i / 100.0 + format("%.2f %s", amount, unit == "EUR" ? "€" : unit) + end + end + end +end diff --git a/backend/app/services/billing/stripe/schedule_downgrade.rb b/backend/app/services/billing/stripe/schedule_downgrade.rb new file mode 100644 index 0000000..ee166cb --- /dev/null +++ b/backend/app/services/billing/stripe/schedule_downgrade.rb @@ -0,0 +1,80 @@ +module Billing + module Stripe + class ScheduleDowngrade + def self.call(club:, plan_slug:, interval:) + new(club: club, plan_slug: plan_slug, interval: interval).call + end + + def initialize(club:, plan_slug:, interval:) + @club = club + @plan_slug = plan_slug.to_s + @interval = PriceCatalog.normalize_interval(interval) + @sub = club.subscription + end + + def call + release_existing_schedule! + + stripe_sub = ::Stripe::Subscription.retrieve(@sub.stripe_subscription_id) + period_start, period_end = SubscriptionPeriod.times(stripe_sub) + raise "Periodo di fatturazione non disponibile" if period_end.blank? + + current_item = stripe_sub.items.data.first + new_price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: @interval) + + schedule = ::Stripe::SubscriptionSchedule.create(from_subscription: stripe_sub.id) + ::Stripe::SubscriptionSchedule.update( + schedule.id, + { + end_behavior: "release", + phases: [ + { + items: [{ price: current_item.price.id, quantity: 1 }], + start_date: schedule_phase_start(stripe_sub, period_start), + end_date: period_end.to_i + }, + { + items: [{ price: new_price_id, quantity: 1 }], + metadata: { + club_id: @club.id, + plan_slug: @plan_slug, + billing_interval: @interval + } + } + ] + } + ) + + pending_plan = Plan[@plan_slug] + @sub.update!( + pending_plan: pending_plan, + pending_billing_interval: @interval, + stripe_schedule_id: schedule.id + ) + + ChangePlanResult.new( + kind: :downgrade, + plan_slug: @plan_slug, + interval: @interval, + effective_at: period_end + ) + end + + private + + def release_existing_schedule! + return if @sub.stripe_schedule_id.blank? + + ::Stripe::SubscriptionSchedule.release(@sub.stripe_schedule_id) + rescue ::Stripe::InvalidRequestError + nil + ensure + @sub.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil) + end + + def schedule_phase_start(stripe_sub, period_start) + period_start&.to_i || stripe_sub.items.data.first.current_period_start + 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 c687950..5055bad 100644 --- a/backend/app/services/billing/stripe/webhook_handler.rb +++ b/backend/app/services/billing/stripe/webhook_handler.rb @@ -14,7 +14,11 @@ module Billing when "checkout.session.completed" CheckoutSync.from_session(@event.data.object) when "customer.subscription.updated", "customer.subscription.created" - CheckoutSync.from_subscription(@event.data.object) + stripe_sub = @event.data.object + CheckoutSync.from_subscription(stripe_sub) + club = Club.joins(:subscription) + .find_by(subscriptions: { stripe_subscription_id: stripe_sub.id }) + ApplyPendingPlan.call(club: club) if club when "customer.subscription.deleted" downgrade_to_free(@event.data.object) when "invoice.payment_failed" diff --git a/backend/app/services/youtube/oauth_state.rb b/backend/app/services/youtube/oauth_state.rb index d0af87f..e392d51 100644 --- a/backend/app/services/youtube/oauth_state.rb +++ b/backend/app/services/youtube/oauth_state.rb @@ -3,8 +3,10 @@ module Youtube PURPOSE = :youtube_oauth_connect class << self - def for_team(team_id:, user_id:) - verifier.generate([team_id.to_s, user_id.to_s], expires_in: 1.hour) + def for_team(team_id:, user_id:, return_app: false) + payload = [team_id.to_s, user_id.to_s] + payload << "app" if return_app + verifier.generate(payload, expires_in: 1.hour) end def for_platform(admin_id:) @@ -17,8 +19,13 @@ module Youtube when Array if payload.first == "platform" { kind: :platform, admin_id: payload[1] } - elsif payload.size == 2 - { kind: :team, team_id: payload[0], user_id: payload[1] } + elsif payload.size >= 2 + { + kind: :team, + team_id: payload[0], + user_id: payload[1], + return_app: payload[2] == "app" + } else raise ArgumentError, "stato OAuth non valido" end diff --git a/backend/app/views/public/clubs/billing.html.erb b/backend/app/views/public/clubs/billing.html.erb index aded247..b54662a 100644 --- a/backend/app/views/public/clubs/billing.html.erb +++ b/backend/app/views/public/clubs/billing.html.erb @@ -6,6 +6,9 @@ <%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %> <%= render "shared/stripe_secure_payment" %> + <% if MatchLiveTv.stripe_enabled? %> + <%= render "shared/plan_change_info", subscription: @subscription %> + <% end %> <%= render "shared/plan_cards", show_stripe_portal: false %> <%= render "shared/subscription_cancel", club: @club, subscription: @subscription %> diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb index ceb19f4..b12c339 100644 --- a/backend/app/views/public/invitations/show.html.erb +++ b/backend/app/views/public/invitations/show.html.erb @@ -5,10 +5,17 @@

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

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

+

+ Nell’app Match Live TV accedi con <%= @invitation.email %>, accetta l’invito e collega il tuo canale YouTube. +

<% if logged_in? %> <%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> <% else %>

<%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.

<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> <% end %> +

+ App mobile: + Apri invito nell’app +

diff --git a/backend/app/views/public/teams/billing.html.erb b/backend/app/views/public/teams/billing.html.erb index 83a181a..d68cda8 100644 --- a/backend/app/views/public/teams/billing.html.erb +++ b/backend/app/views/public/teams/billing.html.erb @@ -8,7 +8,7 @@
<% @plans.each do |plan| %>
-

<%= plan.name %>

+ <%= render "shared/plan_title", plan: plan %> <% if plan.slug == "free" %>
€0
<% elsif plan.slug == "premium_light" %> @@ -27,7 +27,6 @@ <% if plan.slug == @entitlements.plan.slug %> Piano attuale <% elsif plan.slug == "free" %> -

Contatta il supporto per downgrade.

<% elsif MatchLiveTv.stripe_enabled? %> <%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %> <% else %> diff --git a/backend/app/views/shared/_club_subscription_status.html.erb b/backend/app/views/shared/_club_subscription_status.html.erb index e665221..68d6f36 100644 --- a/backend/app/views/shared/_club_subscription_status.html.erb +++ b/backend/app/views/shared/_club_subscription_status.html.erb @@ -16,8 +16,20 @@ <%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %>, poi piano Free. + <% elsif subscription.plan_change_pending? && subscription.pending_plan.present? %> + + Dal <%= subscription.current_period_end.present? ? l_local(subscription.current_period_end) : "prossimo rinnovo" %> + passerai a <%= subscription.pending_plan.name %> + <% if subscription.pending_billing_interval.present? %> + (<%= Billing::Stripe::PriceCatalog.label(plan_slug: subscription.pending_plan.slug, interval: subscription.pending_billing_interval) %>) + <% end %>. + Fino ad allora resta attivo <%= current_plan.name %>. + <% elsif subscription.current_period_end.present? %> Prossimo rinnovo: <%= l_local(subscription.current_period_end) %> + <% if subscription.billing_interval.present? %> + · <%= Billing::Stripe::PriceCatalog.label(plan_slug: current_plan.slug, interval: subscription.billing_interval) %> + <% end %> <% end %>

<% end %> diff --git a/backend/app/views/shared/_plan_cards.html.erb b/backend/app/views/shared/_plan_cards.html.erb index da61398..ef9cac6 100644 --- a/backend/app/views/shared/_plan_cards.html.erb +++ b/backend/app/views/shared/_plan_cards.html.erb @@ -12,10 +12,11 @@ current_slug: current_slug, target_plan: plan, stripe_subscription_active: stripe_subscription_active?(subscription), - current_interval: current_interval + current_interval: current_interval, + subscription: subscription ) : nil %>
-

<%= plan.name %>

+ <%= render "shared/plan_title", plan: plan %> <% if plan.slug == "free" %>
€0
<% elsif plan.slug == "premium_light" %> @@ -39,7 +40,8 @@ <% action_kind = action[:kind] %> <% if action_kind == :current %> <%= action[:label] %> - <% elsif action_kind == :contact || action_kind == :disabled %> + <% elsif action_kind == :none %> + <% elsif action_kind.in?(%i[contact disabled]) %>

<%= action[:label] %>

<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %> <% if billing_profile_blocks_premium?(club) %> @@ -52,16 +54,13 @@ <% else %>
<% plan_intervals_for_display(action[:intervals]).each do |interval| %> - <% btn_kind = action_kind == :checkout_options ? :checkout : :change %> + <% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %> <%= link_to plan_interval_button_label(plan, interval, kind: btn_kind), plan_interval_checkout_path(club, plan, interval), class: plan_interval_button_class(interval) %> <% end %>
<% end %> - <% if action_kind == :interval_switch && action[:current_interval].present? %> -

Fatturazione attuale: <%= Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: action[:current_interval]) %>

- <% end %> <% end %> <% elsif plan.slug == "free" %> <%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %> diff --git a/backend/app/views/shared/_plan_change_info.html.erb b/backend/app/views/shared/_plan_change_info.html.erb new file mode 100644 index 0000000..1c4f8f0 --- /dev/null +++ b/backend/app/views/shared/_plan_change_info.html.erb @@ -0,0 +1,17 @@ +<%# locals: (subscription: nil) %> +<% lines = billing_plan_change_info_lines(subscription: subscription) %> +
+
+ + + +
+

Cambio piano e fatturazione

+
    + <% lines.each do |line| %> +
  • <%= line %>
  • + <% end %> +
+
+
+
diff --git a/backend/app/views/shared/_plan_title.html.erb b/backend/app/views/shared/_plan_title.html.erb new file mode 100644 index 0000000..01906ad --- /dev/null +++ b/backend/app/views/shared/_plan_title.html.erb @@ -0,0 +1,5 @@ +<%# locals: (plan:) %> +

+ + <%= plan.name %> +

diff --git a/backend/app/views/shared/_subscription_cancel.html.erb b/backend/app/views/shared/_subscription_cancel.html.erb index a848283..2be8fe6 100644 --- a/backend/app/views/shared/_subscription_cancel.html.erb +++ b/backend/app/views/shared/_subscription_cancel.html.erb @@ -1,7 +1,7 @@ <%# locals: (club:, subscription:) %> <% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %> -
+
<% if subscription.cancel_at_period_end? %>

Disdetta programmata. diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 2f481a9..850eb76 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -11,6 +11,9 @@ Rails.application.routes.draw do post "auth/refresh", to: "auth#refresh" get "auth/me", to: "auth#me" + get "invitations/:token", to: "invitations#show" + post "invitations/:token/accept", to: "invitations#accept" + resources :teams do member do post :members, action: :add_member diff --git a/backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb b/backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb new file mode 100644 index 0000000..1605bc4 --- /dev/null +++ b/backend/db/migrate/20260602120000_add_pending_plan_to_subscriptions.rb @@ -0,0 +1,9 @@ +class AddPendingPlanToSubscriptions < ActiveRecord::Migration[7.2] + def change + change_table :subscriptions, bulk: true do |t| + t.references :pending_plan, type: :uuid, foreign_key: { to_table: :plans } + t.string :pending_billing_interval + t.string :stripe_schedule_id + end + end +end diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 0153bd6..9bed993 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_31_140000) do +ActiveRecord::Schema[7.2].define(version: 2026_06_02_120000) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -250,7 +250,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do t.datetime "updated_at", null: false t.uuid "club_id", null: false t.string "billing_interval" + t.uuid "pending_plan_id" + t.string "pending_billing_interval" + t.string "stripe_schedule_id" t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true + t.index ["pending_plan_id"], name: "index_subscriptions_on_pending_plan_id" t.index ["plan_id"], name: "index_subscriptions_on_plan_id" t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)" end @@ -353,6 +357,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do add_foreign_key "stream_sessions", "users" add_foreign_key "subscriptions", "clubs" add_foreign_key "subscriptions", "plans" + add_foreign_key "subscriptions", "plans", column: "pending_plan_id" add_foreign_key "team_invitations", "teams" add_foreign_key "team_roster_members", "teams" add_foreign_key "teams", "clubs" diff --git a/backend/public/marketing.css b/backend/public/marketing.css index 8f4d028..0e31423 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -943,7 +943,31 @@ body.nav-menu-open { overflow: hidden; } padding: 24px; box-sizing: border-box; } -.plan-card h3 { margin: 0 0 8px; } +.plan-card h3, +.plan-card__title { + margin: 0 0 8px; +} +.plan-card__title { + display: flex; + align-items: center; + gap: 16px; + font-size: 1.15rem; +} +.plan-card__title i { + flex-shrink: 0; + width: 1.25rem; + text-align: center; + color: #e53935; +} +.plan-card__title i.fa-seedling { + color: #81c784; +} +.plan-card__title i.fa-rocket { + color: #64b5f6; +} +.plan-card__title i.fa-crown { + color: #ffb74d; +} .plan-price { font-size: 1.8rem; font-weight: 800; margin: 12px 0; } .plan-card ul { flex: 1 1 auto; @@ -1190,3 +1214,59 @@ label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; f font-size: 0.85rem; margin: 8px 0 0; } +.plan-change-info-wrap { + margin-top: 16px; +} +.plan-change-info { + display: inline-block; +} +.plan-change-info__trigger { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + cursor: pointer; + color: #888; + list-style: none; + user-select: none; +} +.plan-change-info__trigger:hover { + background: #1e1e28; +} +.plan-change-info__trigger::-webkit-details-marker { + display: none; +} +.plan-change-info__trigger i { + color: #635bff; + font-size: 1.1rem; +} +.plan-change-info[open] .plan-change-info__trigger { + color: #ddd; + background: #1e1e28; +} +.plan-change-info__box { + margin-top: 12px; + max-width: 640px; + padding: 14px 16px; + background: #14141c; + border: 1px solid #2a2a36; + border-radius: 10px; + color: #aaa; + font-size: 0.88rem; + line-height: 1.55; +} +.plan-change-info__title { + margin: 0 0 10px; + color: #ddd; + font-weight: 600; + font-size: 0.92rem; +} +.plan-change-info__box ul { + margin: 0; + padding-left: 1.2rem; +} +.plan-change-info__box li + li { + margin-top: 8px; +} diff --git a/backend/spec/helpers/public/billing_helper_spec.rb b/backend/spec/helpers/public/billing_helper_spec.rb index 3ae67a2..731dbcd 100644 --- a/backend/spec/helpers/public/billing_helper_spec.rb +++ b/backend/spec/helpers/public/billing_helper_spec.rb @@ -67,4 +67,13 @@ RSpec.describe Public::BillingHelper, type: :helper do it "mostra prima il pulsante annuale" do expect(helper.plan_intervals_for_display(%w[monthly yearly])).to eq(%w[yearly monthly]) end + + it "sulla card Free non mostra testo se c'è abbonamento Stripe" do + action = helper.plan_billing_action( + current_slug: "premium_full", + target_plan: Plan["free"], + stripe_subscription_active: true + ) + expect(action[:kind]).to eq(:none) + end end diff --git a/backend/spec/requests/api/v1/invitations_spec.rb b/backend/spec/requests/api/v1/invitations_spec.rb new file mode 100644 index 0000000..c06b93a --- /dev/null +++ b/backend/spec/requests/api/v1/invitations_spec.rb @@ -0,0 +1,54 @@ +require "rails_helper" + +RSpec.describe "Api::V1::Invitations", type: :request do + let(:club) { Club.create!(name: "FC Test", sport: "volleyball", primary_color: "#e53935", secondary_color: "#ffffff") } + let(:team) { club.teams.create!(name: "Squadra A", sport: "volleyball") } + let(:owner) do + User.create!(email: "owner@test.it", name: "Owner", password: "password123", role: "coach").tap do |u| + ClubMembership.create!(user: u, club: club, role: "owner") + end + end + let(:invitee) { User.create!(email: "streamer@test.it", name: "Streamer", password: "password123", role: "coach") } + let(:token) { TeamInvitation.generate_token } + let!(:invitation) do + team.team_invitations.create!( + email: invitee.email, + token_digest: Digest::SHA256.hexdigest(token), + role: "member", + staff_kind: "transmission", + expires_at: 7.days.from_now + ) + end + + describe "GET /api/v1/invitations/:token" do + it "mostra anteprima invito valido" do + get "/api/v1/invitations/#{token}" + expect(response).to have_http_status(:ok) + expect(response.parsed_body["email"]).to eq("streamer@test.it") + expect(response.parsed_body["team_name"]).to eq("Squadra A") + end + end + + describe "POST /api/v1/invitations/:token/accept" do + it "accetta con email corretta" do + post "/api/v1/auth/login", params: { email: invitee.email, password: "password123" } + access = response.parsed_body["access_token"] + + post "/api/v1/invitations/#{token}/accept", + headers: { "Authorization" => "Bearer #{access}" } + + expect(response).to have_http_status(:ok) + expect(invitee.user_teams.find_by(team: team)&.staff_kind).to eq("transmission") + end + + it "rifiuta email diversa" do + post "/api/v1/auth/login", params: { email: owner.email, password: "password123" } + access = response.parsed_body["access_token"] + + post "/api/v1/invitations/#{token}/accept", + headers: { "Authorization" => "Bearer #{access}" } + + expect(response).to have_http_status(:unprocessable_entity) + end + end +end diff --git a/backend/spec/services/billing/stripe/change_plan_spec.rb b/backend/spec/services/billing/stripe/change_plan_spec.rb index e02b96e..2f27e32 100644 --- a/backend/spec/services/billing/stripe/change_plan_spec.rb +++ b/backend/spec/services/billing/stripe/change_plan_spec.rb @@ -11,48 +11,69 @@ RSpec.describe Billing::Stripe::ChangePlan do status: "active", stripe_attrs: { stripe_customer_id: "cus_test", - stripe_subscription_id: "sub_test_123" + stripe_subscription_id: "sub_test_123", + billing_interval: "monthly", + current_period_end: 1.month.from_now } ) allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true) - allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y") allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m") - allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y") + allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y") allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m") + allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y") end - it "aggiorna Stripe e il piano locale verso premium_full" do - stripe_item = double(id: "si_test") + it "upgrade: always_invoice e addebito immediato" do + stripe_item = double(id: "si_test", price: double(id: "price_light_m")) 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_sub).to receive(:current_period_start).and_return(Time.current.to_i) + allow(stripe_sub).to receive(:current_period_end).and_return(1.month.from_now.to_i) + allow(stripe_item).to receive(:current_period_start).and_return(Time.current.to_i) + allow(stripe_item).to receive(:current_period_end).and_return(1.month.from_now.to_i) + invoice = double(id: "in_up", status: "paid", amount_paid: 1500, currency: "eur") allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub) - allow(Stripe::Subscription).to receive(:update).and_return(updated_sub) + allow(Stripe::Subscription).to receive(:update).and_return(stripe_sub) + allow(Stripe::Invoice).to receive(:list).and_return(double(data: [invoice])) + allow(Billing::Stripe::RecordPayment).to receive(:call) - described_class.call(club: club, plan_slug: "premium_full", interval: "yearly") + result = described_class.call(club: club, plan_slug: "premium_full", interval: "monthly") expect(Stripe::Subscription).to have_received(:update).with( "sub_test_123", - hash_including( - items: [{ id: "si_test", price: "price_full_y" }], - metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" } - ) + hash_including(proration_behavior: "always_invoice", payment_behavior: "error_if_incomplete") ) + expect(result).to be_upgrade expect(club.reload.subscription.plan.slug).to eq("premium_full") end - it "rifiuta il cambio sullo stesso piano e intervallo" do - club.subscription.update!(billing_interval: "yearly") - expect { - described_class.call(club: club, plan_slug: "premium_light", interval: "yearly") - }.to raise_error(/già su questo piano/) + it "downgrade: programma ScheduleDowngrade senza cambiare piano subito" do + Billing::AssignPlan.call( + club: club, + plan_slug: "premium_full", + status: "active", + stripe_attrs: { stripe_subscription_id: "sub_test_123", billing_interval: "monthly" } + ) + + allow(Billing::Stripe::ScheduleDowngrade).to receive(:call).and_return( + Billing::Stripe::ChangePlanResult.new( + kind: :downgrade, + plan_slug: "premium_light", + interval: "monthly", + effective_at: 1.month.from_now + ) + ) + + result = described_class.call(club: club, plan_slug: "premium_light", interval: "monthly") + + expect(Billing::Stripe::ScheduleDowngrade).to have_received(:call) + expect(result).to be_downgrade + expect(club.reload.subscription.plan.slug).to eq("premium_full") end end diff --git a/backend/spec/services/billing/stripe/plan_change_kind_spec.rb b/backend/spec/services/billing/stripe/plan_change_kind_spec.rb new file mode 100644 index 0000000..320c9fa --- /dev/null +++ b/backend/spec/services/billing/stripe/plan_change_kind_spec.rb @@ -0,0 +1,32 @@ +require "rails_helper" + +RSpec.describe Billing::Stripe::PlanChangeKind do + before { load Rails.root.join("db/seeds/plans.rb") } + + it "classifica light → full come upgrade" do + expect(described_class.classify( + from_plan_slug: "premium_light", + from_interval: "monthly", + to_plan_slug: "premium_full", + to_interval: "monthly" + )).to eq(:upgrade) + end + + it "classifica full → light come downgrade" do + expect(described_class.classify( + from_plan_slug: "premium_full", + from_interval: "monthly", + to_plan_slug: "premium_light", + to_interval: "monthly" + )).to eq(:downgrade) + end + + it "classifica passaggio a annuale come upgrade sullo stesso piano" do + expect(described_class.classify( + from_plan_slug: "premium_light", + from_interval: "monthly", + to_plan_slug: "premium_light", + to_interval: "yearly" + )).to eq(:upgrade) + end +end diff --git a/docs/APP_YOUTUBE_LIVE.md b/docs/APP_YOUTUBE_LIVE.md new file mode 100644 index 0000000..21ba253 --- /dev/null +++ b/docs/APP_YOUTUBE_LIVE.md @@ -0,0 +1,59 @@ +# Diretta YouTube dall’app (invito staff) + +Percorso per il **responsabile trasmissione** invitato via email: accede in app, collega **il proprio** canale YouTube e avvia la live. + +## Prerequisiti (società) + +- Piano **Premium Full** attivo +- `YOUTUBE_CLIENT_ID` / `YOUTUBE_CLIENT_SECRET` configurati sul server +- Partita programmata per la squadra + +## 1. Invito + +1. Il titolare società → **Responsabili trasmissione** → invita `tua@email.it` +2. L’invitato apre il link (web o **Apri invito nell’app**) +3. Registrazione / login con **la stessa email** dell’invito +4. L’app accetta l’invito automaticamente dopo il login + +API: `GET /api/v1/invitations/:token`, `POST /api/v1/invitations/:token/accept` + +## 2. Collega canale YouTube (tuo) + +1. Nell’app: nuova diretta → passo **Piattaforma** +2. Tocca **YouTube Live** → si apre Google OAuth +3. **Avanzate** → continua (se app OAuth in verifica) +4. Scegli l’account del **tuo** canale → Consenti +5. Torna all’app → messaggio «Canale collegato» + +Il token OAuth è salvato sulla **squadra**; chi collega deve essere `can_stream` (responsabile trasmissione o titolare). + +## 3. Scegli la partita + +Nella home app (**Partite**): + +- **Partita programmata** — scegli dal calendario una gara già inserita (sito o app) +- **Nuova partita** — programma data/ora oppure **Avvia subito** senza orario + +Poi tocca la card o conferma dal foglio: si apre il wizard (Partita → Trasmissione → …). + +## 4. Avvia diretta + +1. Nel wizard, seleziona **YouTube Live** (dopo il collegamento canale) +2. Completa i passi → **Camera** +3. Il telefono invia RTMP a MediaMTX; il server inolvia su YouTube con la stream key della live creata via API + +## Sviluppo locale + +- App: `flutter run --dart-define=API_BASE_URL=http://10.0.2.2:3000` (emulatore) o IP LAN del PC (telefono) +- Invito deep link: `matchlivetv://join/TOKEN` oppure incolla token aprendo `/login?invite=TOKEN` +- OAuth: utente in **Utenti di test** Google Cloud + +## Troubleshooting + +| Problema | Soluzione | +|----------|-----------| +| Nessuna squadra in app | Accetta invito con email corretta | +| YouTube disabilitato | Premium Full + collegamento canale | +| «Collega canale» | Tocca YouTube Live per OAuth da app | +| OAuth «app non verificata» | Utente di test Google + Avanzate | +| Live non su YouTube | Verifica relay ffmpeg / `stream_key` in sessione | diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index ea6108b..8c44282 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -34,6 +34,18 @@ + + + + + + + + + + + + ((ref) { ), GoRoute( path: '/login', - builder: (context, state) => const LoginScreen(), + builder: (context, state) => LoginScreen( + inviteToken: state.uri.queryParameters['invite'], + ), ), GoRoute( path: '/matches', diff --git a/mobile/lib/core/deep_link_listener.dart b/mobile/lib/core/deep_link_listener.dart new file mode 100644 index 0000000..f9a5c1f --- /dev/null +++ b/mobile/lib/core/deep_link_listener.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:app_links/app_links.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../features/matches/team_providers.dart'; +import 'invite_storage.dart'; + +/// Gestisce link invito (join) e ritorno OAuth YouTube dall'app. +class DeepLinkListener extends ConsumerStatefulWidget { + const DeepLinkListener({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => _DeepLinkListenerState(); +} + +class _DeepLinkListenerState extends ConsumerState { + final _appLinks = AppLinks(); + StreamSubscription? _sub; + + @override + void initState() { + super.initState(); + _handleInitialLink(); + _sub = _appLinks.uriLinkStream.listen(_onUri, onError: (_) {}); + } + + Future _handleInitialLink() async { + final uri = await _appLinks.getInitialLink(); + if (uri != null) await _onUri(uri); + } + + Future _onUri(Uri uri) async { + final inviteToken = _extractInviteToken(uri); + if (inviteToken != null) { + await InviteStorage.saveToken(inviteToken); + if (!mounted) return; + context.go('/login?invite=$inviteToken'); + return; + } + + if (uri.scheme == 'matchlivetv' && uri.host == 'youtube-connected') { + ref.invalidate(teamsProvider); + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Canale YouTube collegato. Puoi selezionare YouTube Live.'), + duration: Duration(seconds: 4), + ), + ); + } + } + + String? _extractInviteToken(Uri uri) { + if (uri.scheme == 'matchlivetv' && uri.host == 'join') { + final segment = uri.pathSegments.isNotEmpty ? uri.pathSegments.first : uri.path.replaceFirst('/', ''); + return segment.isEmpty ? null : segment; + } + if (uri.pathSegments.length >= 2 && uri.pathSegments.first == 'join') { + return uri.pathSegments[1]; + } + return null; + } + + @override + void dispose() { + _sub?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/mobile/lib/core/invite_storage.dart b/mobile/lib/core/invite_storage.dart new file mode 100644 index 0000000..587edc4 --- /dev/null +++ b/mobile/lib/core/invite_storage.dart @@ -0,0 +1,20 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class InviteStorage { + static const _keyToken = 'pending_invite_token'; + + static Future saveToken(String token) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyToken, token); + } + + static Future readToken() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(_keyToken); + } + + static Future clear() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyToken); + } +} diff --git a/mobile/lib/features/auth/login_screen.dart b/mobile/lib/features/auth/login_screen.dart index 291404e..f4ebed7 100644 --- a/mobile/lib/features/auth/login_screen.dart +++ b/mobile/lib/features/auth/login_screen.dart @@ -5,16 +5,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../app/theme.dart'; -import '../../features/matches/matches_screen.dart'; +import '../../core/invite_storage.dart'; import '../../features/matches/team_providers.dart'; import '../../providers/auth_provider.dart'; import '../../shared/api_client.dart'; +import '../../shared/models/invite_preview.dart'; import '../../shared/widgets/match_live_wordmark.dart'; import '../../shared/widgets/primary_cta.dart'; import '../../shared/widgets/screen_insets.dart'; class LoginScreen extends ConsumerStatefulWidget { - const LoginScreen({super.key}); + const LoginScreen({super.key, this.inviteToken}); + + final String? inviteToken; @override ConsumerState createState() => _LoginScreenState(); @@ -22,9 +25,44 @@ class LoginScreen extends ConsumerStatefulWidget { class _LoginScreenState extends ConsumerState { final _formKey = GlobalKey(); - final _emailController = TextEditingController(text: 'coach@matchlivetv.test'); + final _emailController = TextEditingController(); final _passwordController = TextEditingController(text: 'password123'); bool _obscure = true; + InvitePreview? _invite; + String? _inviteToken; + bool _loadingInvite = false; + + @override + void initState() { + super.initState(); + _bootstrapInvite(); + } + + Future _bootstrapInvite() async { + _inviteToken = widget.inviteToken ?? await InviteStorage.readToken(); + if (_inviteToken == null) return; + + setState(() => _loadingInvite = true); + try { + final preview = await ApiClient().fetchInvitePreview(_inviteToken!); + if (!mounted) return; + setState(() { + _invite = preview; + if (preview.email.isNotEmpty) { + _emailController.text = preview.email; + } + }); + } catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Invito non valido o scaduto')), + ); + } + await InviteStorage.clear(); + } finally { + if (mounted) setState(() => _loadingInvite = false); + } + } @override void dispose() { @@ -33,6 +71,28 @@ class _LoginScreenState extends ConsumerState { super.dispose(); } + Future _acceptInviteIfNeeded(ApiClient client) async { + final token = _inviteToken; + if (token == null) return; + + try { + final message = await client.acceptInvitation(token); + await InviteStorage.clear(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } + } on DioException catch (e) { + final err = e.response?.data; + final msg = err is Map ? (err['error'] as String?) : null; + if (mounted && msg != null) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg))); + } + rethrow; + } + } + Future _login() async { if (!_formKey.currentState!.validate()) return; @@ -56,12 +116,22 @@ class _LoginScreenState extends ConsumerState { accessToken: result.tokens.accessToken, refreshToken: result.tokens.refreshToken, ); + + final authed = ApiClient(accessToken: result.tokens.accessToken); + if (_inviteToken != null) { + await _acceptInviteIfNeeded(authed); + } + ref.invalidate(teamsProvider); ref.invalidate(matchesProvider); ref.read(authProvider.notifier).setLoading(false); if (mounted) context.go('/matches'); return; } on DioException catch (e) { + if (e.response?.statusCode == 422) { + ref.read(authProvider.notifier).setLoading(false); + return; + } final message = switch (e.type) { DioExceptionType.connectionTimeout || DioExceptionType.receiveTimeout || @@ -91,63 +161,103 @@ class _LoginScreenState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const SizedBox(height: 32), - const Center(child: MatchLiveWordmark(showSlogan: true)), - const SizedBox(height: 48), - Text( - 'ACCEDI', - style: theme.textTheme.headlineMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 8), - Text( - 'Gestisci le dirette della tua squadra', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), - const SizedBox(height: 32), - TextFormField( - controller: _emailController, - keyboardType: TextInputType.emailAddress, - decoration: const InputDecoration( - labelText: 'Email', - hintText: 'coach@squadra.it', + const SizedBox(height: 32), + const Center(child: MatchLiveWordmark(showSlogan: true)), + const SizedBox(height: 48), + if (_loadingInvite) + const Padding( + padding: EdgeInsets.only(bottom: 16), + child: Center( + child: CircularProgressIndicator(color: AppTheme.primaryRed), ), - validator: (v) => - v == null || v.isEmpty ? 'Inserisci l\'email' : null, ), - const SizedBox(height: 16), - TextFormField( - controller: _passwordController, - obscureText: _obscure, - decoration: InputDecoration( - labelText: 'Password', - suffixIcon: IconButton( - icon: Icon( - _obscure ? Icons.visibility : Icons.visibility_off, + if (_invite != null) ...[ + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.35)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Invito staff', + style: theme.textTheme.titleMedium?.copyWith(color: AppTheme.primaryRed), ), - onPressed: () => setState(() => _obscure = !_obscure), - ), + const SizedBox(height: 6), + Text( + 'Entra in ${_invite!.teamName} come responsabile trasmissione.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 4), + Text( + 'Accedi con ${_invite!.email}', + style: theme.textTheme.bodySmall, + ), + ], ), - validator: (v) => - v == null || v.isEmpty ? 'Inserisci la password' : null, ), - if (auth.error != null) ...[ - const SizedBox(height: 12), - Text( - auth.error!, - style: theme.textTheme.bodyMedium?.copyWith( - color: AppTheme.primaryRed, + const SizedBox(height: 20), + ], + Text( + 'ACCEDI', + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + _invite != null + ? 'Usa l\'email dell\'invito, poi collega YouTube e vai in diretta' + : 'Gestisci le dirette della tua squadra', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 32), + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + readOnly: _invite != null, + decoration: const InputDecoration( + labelText: 'Email', + hintText: 'coach@squadra.it', + ), + validator: (v) => + v == null || v.isEmpty ? 'Inserisci l\'email' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + obscureText: _obscure, + decoration: InputDecoration( + labelText: 'Password', + suffixIcon: IconButton( + icon: Icon( + _obscure ? Icons.visibility : Icons.visibility_off, ), - textAlign: TextAlign.center, + onPressed: () => setState(() => _obscure = !_obscure), ), - ], - const SizedBox(height: 32), - PrimaryCta( - label: 'ACCEDI', - loading: auth.loading, - onPressed: _login, ), + validator: (v) => + v == null || v.isEmpty ? 'Inserisci la password' : null, + ), + if (auth.error != null) ...[ + const SizedBox(height: 12), + Text( + auth.error!, + style: theme.textTheme.bodyMedium?.copyWith( + color: AppTheme.primaryRed, + ), + textAlign: TextAlign.center, + ), + ], + const SizedBox(height: 32), + PrimaryCta( + label: _invite != null ? 'ACCEDI E ACCETTA INVITO' : 'ACCEDI', + loading: auth.loading, + onPressed: _login, + ), ], ), ), diff --git a/mobile/lib/features/matches/matches_screen.dart b/mobile/lib/features/matches/matches_screen.dart index 15d8af2..51e8698 100644 --- a/mobile/lib/features/matches/matches_screen.dart +++ b/mobile/lib/features/matches/matches_screen.dart @@ -9,11 +9,12 @@ import '../../providers/auth_provider.dart'; import '../../shared/api_client.dart'; import '../../shared/models/match.dart'; import '../../shared/widgets/match_live_wordmark.dart'; -import '../../shared/widgets/primary_cta.dart'; import '../../shared/widgets/screen_insets.dart'; import 'resume_session_sheet.dart'; import 'no_team_onboarding.dart'; +import 'new_match_sheet.dart'; import 'schedule_match_sheet.dart'; +import 'select_match_sheet.dart'; import 'team_picker.dart'; import 'team_providers.dart'; @@ -127,9 +128,38 @@ class MatchesScreen extends ConsumerWidget { ), const SizedBox(height: 4), Text( - 'Le tue partite', + 'Scegli una partita programmata o creane una nuova.', style: theme.textTheme.bodyMedium, ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _pickScheduledMatch(context, ref, matches), + icon: const Icon(Icons.playlist_play), + label: const Text('Partita\nprogrammata'), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 14), + alignment: Alignment.center, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: () => _newMatch(context, ref), + icon: const Icon(Icons.add), + label: const Text('Nuova\npartita'), + style: FilledButton.styleFrom( + backgroundColor: AppTheme.primaryRed, + padding: const EdgeInsets.symmetric(vertical: 14), + alignment: Alignment.center, + ), + ), + ), + ], + ), const TeamPickerBar(), if (activeMatch != null) ...[ const SizedBox(height: 12), @@ -186,17 +216,25 @@ class MatchesScreen extends ConsumerWidget { ), ), ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 8), + child: Text( + matches.isEmpty ? 'Calendario vuoto' : 'Calendario', + style: theme.textTheme.labelLarge?.copyWith( + color: AppTheme.textSecondary, + ), + ), + ), + ), if (matches.isEmpty) - SliverFillRemaining( - hasScrollBody: false, - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - 'Nessuna partita in calendario.\nTocca «Programma partita» per aggiungere data e ora.', - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, - ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text( + 'Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.', + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, ), ), ) @@ -208,13 +246,7 @@ class MatchesScreen extends ConsumerWidget { return _MatchCard( match: match, dateFormat: dateFormat, - onTap: () { - if (match.hasActiveSession) { - showResumeSessionSheet(context, ref, match: match); - } else { - context.push('/setup/${match.id}/1'); - } - }, + onTap: () => _openMatch(context, ref, match), onDelete: match.canResumeCamera ? null : () => _deleteMatch(context, ref, match), @@ -224,24 +256,7 @@ class MatchesScreen extends ConsumerWidget { ), ), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 8), - child: PrimaryCta( - label: 'PROGRAMMA PARTITA', - icon: Icons.event_available, - onPressed: () => _scheduleMatch(context, ref), - ), - ), - ), - SliverToBoxAdapter( - child: Padding( - padding: EdgeInsets.fromLTRB(20, 0, 20, 20 + bottomInset), - child: TextButton.icon( - onPressed: () => _createMatchQuick(context, ref), - icon: const Icon(Icons.settings_outlined, size: 18), - label: const Text('Crea e configura subito (senza orario)'), - ), - ), + child: SizedBox(height: 20 + bottomInset), ), ], ), @@ -299,6 +314,48 @@ class MatchesScreen extends ConsumerWidget { } } + void _openMatch(BuildContext context, WidgetRef ref, MatchModel match) { + if (match.hasActiveSession) { + showResumeSessionSheet(context, ref, match: match); + } else { + context.push('/setup/${match.id}/1'); + } + } + + Future _pickScheduledMatch( + BuildContext context, + WidgetRef ref, + List matches, + ) async { + final selectable = matches.where((m) => !m.hasActiveSession).toList(); + if (selectable.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Nessuna partita disponibile. Crea una nuova partita.'), + ), + ); + } + return; + } + + final picked = await showSelectMatchSheet(context, matches: matches); + if (picked != null && context.mounted) { + context.push('/setup/${picked.id}/1'); + } + } + + Future _newMatch(BuildContext context, WidgetRef ref) async { + final choice = await showNewMatchSheet(context); + if (choice == null || !context.mounted) return; + switch (choice) { + case NewMatchChoice.schedule: + await _scheduleMatch(context, ref); + case NewMatchChoice.quickStart: + await _createMatchQuick(context, ref); + } + } + Future _scheduleMatch(BuildContext context, WidgetRef ref) async { final team = await ref.read(activeTeamProvider.future); if (team == null) { diff --git a/mobile/lib/features/matches/new_match_sheet.dart b/mobile/lib/features/matches/new_match_sheet.dart new file mode 100644 index 0000000..875a552 --- /dev/null +++ b/mobile/lib/features/matches/new_match_sheet.dart @@ -0,0 +1,105 @@ +import 'package:flutter/material.dart'; + +import '../../app/theme.dart'; + +enum NewMatchChoice { schedule, quickStart } + +Future showNewMatchSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) { + final theme = Theme.of(ctx); + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text('Nuova partita', style: theme.textTheme.titleLarge), + const SizedBox(height: 6), + Text( + 'Programma in anticipo o avvia la configurazione diretta subito.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 20), + _OptionTile( + icon: Icons.event_available, + title: 'Programma partita', + subtitle: 'Data, ora e avversario — visibile anche sul sito', + onTap: () => Navigator.pop(ctx, NewMatchChoice.schedule), + ), + const SizedBox(height: 10), + _OptionTile( + icon: Icons.play_circle_outline, + title: 'Avvia subito', + subtitle: 'Crea la partita e passa al wizard senza orario', + onTap: () => Navigator.pop(ctx, NewMatchChoice.quickStart), + ), + ], + ), + ); + }, + ); +} + +class _OptionTile extends StatelessWidget { + const _OptionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(icon, color: AppTheme.primaryRed, size: 28), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: theme.textTheme.titleMedium), + const SizedBox(height: 4), + Text(subtitle, style: theme.textTheme.bodySmall), + ], + ), + ), + const Icon(Icons.chevron_right, color: AppTheme.textSecondary), + ], + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/matches/select_match_sheet.dart b/mobile/lib/features/matches/select_match_sheet.dart new file mode 100644 index 0000000..dedab7e --- /dev/null +++ b/mobile/lib/features/matches/select_match_sheet.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +import '../../app/theme.dart'; +import '../../shared/models/match.dart'; + +/// Scegli una partita già in calendario per avviare la diretta. +Future showSelectMatchSheet( + BuildContext context, { + required List matches, +}) { + final selectable = matches.where((m) => !m.hasActiveSession).toList(); + if (selectable.isEmpty) return Future.value(null); + + return showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: AppTheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (ctx) => _SelectMatchSheet(matches: selectable), + ); +} + +class _SelectMatchSheet extends StatelessWidget { + const _SelectMatchSheet({required this.matches}); + + final List matches; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final dateFormat = DateFormat('EEE d MMM · HH:mm', 'it_IT'); + final now = DateTime.now(); + + final scheduled = matches + .where((m) => m.scheduledAt != null) + .toList() + ..sort((a, b) => a.scheduledAt!.compareTo(b.scheduledAt!)); + + final unscheduled = matches.where((m) => m.scheduledAt == null).toList(); + + return DraggableScrollableSheet( + expand: false, + initialChildSize: 0.55, + minChildSize: 0.35, + maxChildSize: 0.9, + builder: (context, scrollController) { + return Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: AppTheme.textSecondary.withValues(alpha: 0.4), + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + Text('Scegli partita', style: theme.textTheme.titleLarge), + const SizedBox(height: 6), + Text( + 'Partite già programmate sul sito o in app.', + style: theme.textTheme.bodyMedium, + ), + const SizedBox(height: 16), + Expanded( + child: ListView( + controller: scrollController, + children: [ + if (scheduled.isNotEmpty) ...[ + Text('Programmate', style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + ...scheduled.map( + (m) => _MatchTile( + match: m, + dateFormat: dateFormat, + now: now, + onTap: () => Navigator.pop(context, m), + ), + ), + const SizedBox(height: 16), + ], + if (unscheduled.isNotEmpty) ...[ + Text('Senza orario', style: theme.textTheme.labelLarge), + const SizedBox(height: 8), + ...unscheduled.map( + (m) => _MatchTile( + match: m, + dateFormat: dateFormat, + now: now, + onTap: () => Navigator.pop(context, m), + ), + ), + ], + ], + ), + ), + ], + ), + ); + }, + ); + } +} + +class _MatchTile extends StatelessWidget { + const _MatchTile({ + required this.match, + required this.dateFormat, + required this.now, + required this.onTap, + }); + + final MatchModel match; + final DateFormat dateFormat; + final DateTime now; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final at = match.scheduledAt; + final isFuture = at != null && at.isAfter(now); + + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Material( + color: AppTheme.surfaceElevated, + borderRadius: BorderRadius.circular(10), + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${match.teamName} vs ${match.opponentName}', + style: theme.textTheme.titleSmall, + ), + if (at != null) + Text( + dateFormat.format(at.toLocal()), + style: theme.textTheme.bodySmall, + ), + if (match.location != null && match.location!.isNotEmpty) + Text(match.location!, style: theme.textTheme.bodySmall), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isFuture + ? AppTheme.primaryRed.withValues(alpha: 0.15) + : AppTheme.textSecondary.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + at != null ? (isFuture ? 'PROGRAMMATA' : 'IN CALENDARIO') : 'BOZZA', + style: theme.textTheme.labelSmall?.copyWith( + color: isFuture ? AppTheme.primaryRed : AppTheme.textSecondary, + fontSize: 10, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/features/setup_wizard/step_transmission.dart b/mobile/lib/features/setup_wizard/step_transmission.dart index fc2c4cb..930b2a3 100644 --- a/mobile/lib/features/setup_wizard/step_transmission.dart +++ b/mobile/lib/features/setup_wizard/step_transmission.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../app/theme.dart'; import '../../providers/session_provider.dart'; @@ -74,16 +75,53 @@ class _StepTransmissionState extends ConsumerState { } } + Future _connectYoutube(Team team) async { + try { + final url = await ref.read(apiClientProvider).youtubeAuthorizeUrl(team.id); + final uri = Uri.parse(url); + if (!await canLaunchUrl(uri)) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Impossibile aprire il browser per Google')), + ); + } + return; + } + await launchUrl(uri, mode: LaunchMode.externalApplication); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text( + 'Completa il consenso Google, poi torna qui e seleziona YouTube Live.', + ), + duration: Duration(seconds: 5), + ), + ); + } + } on DioException catch (e) { + final apiErr = ApiException.fromDio(e); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(apiErr?.message ?? 'Errore collegamento YouTube')), + ); + } + } + } + void _onYoutubeTap(Team? team) { if (team == null) return; if (team.isYoutubeReady) { setState(() => _platform = 'youtube'); return; } + if (team.canUseYoutube && team.youtubeMode == 'team' && team.canConnectYoutube) { + _connectYoutube(team); + return; + } if (team.canUseYoutube && team.youtubeMode == 'team') { showPremiumRequiredDialog( context, - message: 'Collega il canale YouTube dalla pagina Dettagli squadra sul sito, poi seleziona YouTube qui.', + message: 'Collega il canale YouTube (Premium Full) per trasmettere sul tuo canale.', billingUrl: team.staffManageUrl ?? team.billingUrl, ); return; @@ -119,7 +157,10 @@ class _StepTransmissionState extends ConsumerState { if (team.youtubeMode == 'matchlivetv_light') { return 'Canale Match Live TV (in attivazione)'; } - return 'Collega canale sul sito'; + if (team.canConnectYoutube) { + return 'Tocca per collegare il tuo canale YouTube'; + } + return 'Collega canale (Premium Full)'; } void _onExternalPremiumTap(String platform, Team? team) { diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 531d6a3..88fc463 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -5,6 +5,7 @@ import 'package:intl/date_symbol_data_local.dart'; import 'app/router.dart'; import 'app/theme.dart'; +import 'core/deep_link_listener.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -28,11 +29,13 @@ class MatchLiveTvApp extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final router = ref.watch(routerProvider); - return MaterialApp.router( - title: 'Match Live TV', - debugShowCheckedModeBanner: false, - theme: AppTheme.dark, - routerConfig: router, + return DeepLinkListener( + child: MaterialApp.router( + title: 'Match Live TV', + debugShowCheckedModeBanner: false, + theme: AppTheme.dark, + routerConfig: router, + ), ); } } diff --git a/mobile/lib/shared/api_client.dart b/mobile/lib/shared/api_client.dart index da73f9e..3b4efc3 100644 --- a/mobile/lib/shared/api_client.dart +++ b/mobile/lib/shared/api_client.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/config.dart'; import '../providers/auth_provider.dart'; +import 'models/invite_preview.dart'; import 'models/match.dart'; import 'models/recording_item.dart'; import 'models/stream_session.dart'; @@ -86,6 +87,24 @@ class ApiClient { .toList(); } + Future fetchInvitePreview(String token) async { + final response = await _dio.get>('/invitations/$token'); + return InvitePreview.fromJson(response.data!); + } + + Future acceptInvitation(String token) async { + final response = await _dio.post>('/invitations/$token/accept'); + return response.data!['message'] as String? ?? 'Invito accettato'; + } + + Future youtubeAuthorizeUrl(String teamId, {bool returnApp = true}) async { + final response = await _dio.get>( + '/teams/$teamId/youtube/authorize', + queryParameters: returnApp ? {'return_app': '1'} : null, + ); + return response.data!['authorization_url'] as String; + } + Future> fetchRecordings(String teamId) async { final response = await _dio.get>('/teams/$teamId/recordings'); return response.data! diff --git a/mobile/lib/shared/models/invite_preview.dart b/mobile/lib/shared/models/invite_preview.dart new file mode 100644 index 0000000..e4f6319 --- /dev/null +++ b/mobile/lib/shared/models/invite_preview.dart @@ -0,0 +1,28 @@ +class InvitePreview { + const InvitePreview({ + required this.valid, + required this.email, + required this.teamName, + this.clubName, + this.teamId, + this.error, + }); + + final bool valid; + final String email; + final String teamName; + final String? clubName; + final String? teamId; + final String? error; + + factory InvitePreview.fromJson(Map json) { + return InvitePreview( + valid: json['valid'] as bool? ?? false, + email: json['email'] as String? ?? '', + teamName: json['team_name'] as String? ?? '', + clubName: json['club_name'] as String?, + teamId: json['team_id'] as String?, + error: json['error'] as String?, + ); + } +} diff --git a/mobile/lib/shared/models/team.dart b/mobile/lib/shared/models/team.dart index 63089fe..1a940ac 100644 --- a/mobile/lib/shared/models/team.dart +++ b/mobile/lib/shared/models/team.dart @@ -29,6 +29,7 @@ class Team { this.staffRole, this.canStream = true, this.youtubeMode, + this.canConnectYoutube = false, }); final String id; @@ -60,6 +61,7 @@ class Team { final bool phoneDownloadEnabled; final bool youtubeEnabled; final String? youtubeMode; + final bool canConnectYoutube; bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light'); @@ -105,6 +107,7 @@ class Team { phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false, youtubeEnabled: json['youtube_enabled'] as bool? ?? false, youtubeMode: json['youtube_mode'] as String?, + canConnectYoutube: json['can_connect_youtube'] as bool? ?? false, ); } } diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 22c4bf6..32513c6 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.1.0+2 +version: 1.2.0+3 environment: sdk: ^3.12.0 @@ -25,6 +25,7 @@ dependencies: shared_preferences: ^2.5.3 url_launcher: ^6.3.1 share_plus: ^10.1.4 + app_links: ^6.4.0 dev_dependencies: flutter_test: