Billing upgrade/downgrade, inviti in app e diretta YouTube da mobile.
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 <cursoragent@cursor.com>
This commit is contained in:
52
backend/app/controllers/api/v1/invitations_controller.rb
Normal file
52
backend/app/controllers/api/v1/invitations_controller.rb
Normal file
@@ -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
|
||||||
@@ -103,7 +103,8 @@ module Api
|
|||||||
youtube_enabled: ent.youtube_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),
|
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
|
if detail
|
||||||
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
||||||
|
|||||||
@@ -2,11 +2,18 @@ module Api
|
|||||||
module V1
|
module V1
|
||||||
class YoutubeController < BaseController
|
class YoutubeController < BaseController
|
||||||
def authorize
|
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!
|
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"))
|
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
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -96,16 +96,27 @@ module Public
|
|||||||
sub = @club.subscription
|
sub = @club.subscription
|
||||||
|
|
||||||
if sub&.stripe_subscription_id.present? && sub.active? &&
|
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),
|
redirect_to public_club_billing_path(@club),
|
||||||
notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})."
|
notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})."
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
if sub&.stripe_subscription_id.present? && sub.active?
|
if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug &&
|
||||||
Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
|
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),
|
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
|
else
|
||||||
url = Billing::Stripe::CheckoutSession.new(
|
url = Billing::Stripe::CheckoutSession.new(
|
||||||
club: @club, user: current_user, plan_slug: plan_slug, interval: interval
|
club: @club, user: current_user, plan_slug: plan_slug, interval: interval
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ module Public
|
|||||||
end
|
end
|
||||||
|
|
||||||
def youtube_connect
|
def youtube_connect
|
||||||
require_club_owner_for_team!(@team)
|
require_youtube_connect_access!(@team)
|
||||||
@team.entitlements.assert_can_connect_youtube!
|
@team.entitlements.assert_can_connect_youtube!
|
||||||
|
|
||||||
if ENV["YOUTUBE_CLIENT_ID"].blank?
|
if ENV["YOUTUBE_CLIENT_ID"].blank?
|
||||||
@@ -124,7 +124,12 @@ module Public
|
|||||||
return
|
return
|
||||||
end
|
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
|
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
|
||||||
rescue Teams::EntitlementError => e
|
rescue Teams::EntitlementError => e
|
||||||
redirect_to public_team_details_path(@team), alert: e.message
|
redirect_to public_team_details_path(@team), alert: e.message
|
||||||
@@ -138,6 +143,13 @@ module Public
|
|||||||
|
|
||||||
private
|
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!
|
def load_team_details!
|
||||||
@club = @team.club
|
@club = @team.club
|
||||||
@can_manage = @club.owned_by?(current_user)
|
@can_manage = @club.owned_by?(current_user)
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ class YoutubeOauthCallbackController < ActionController::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
user = User.find(ctx[:user_id])
|
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 = team.youtube_credential || team.build_youtube_credential
|
||||||
cred.update!(
|
cred.update!(
|
||||||
access_token: tokens[:access_token],
|
access_token: tokens[:access_token],
|
||||||
@@ -22,7 +26,12 @@ class YoutubeOauthCallbackController < ActionController::Base
|
|||||||
channel_id: tokens[:channel_id],
|
channel_id: tokens[:channel_id],
|
||||||
channel_title: tokens[:channel_title]
|
channel_title: tokens[:channel_title]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
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
|
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
|
||||||
|
end
|
||||||
rescue ArgumentError, Youtube::BroadcastService::Error => e
|
rescue ArgumentError, Youtube::BroadcastService::Error => e
|
||||||
redirect_to_oauth_error(e.message)
|
redirect_to_oauth_error(e.message)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
module ApplicationHelper
|
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.
|
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito.
|
||||||
def l_local(date_or_time, format: :long)
|
def l_local(date_or_time, format: :long)
|
||||||
return nil if date_or_time.blank?
|
return nil if date_or_time.blank?
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
module Public
|
module Public
|
||||||
module BillingHelper
|
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"
|
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
|
end
|
||||||
|
|
||||||
unless MatchLiveTv.stripe_enabled?
|
unless MatchLiveTv.stripe_enabled?
|
||||||
@@ -16,20 +17,34 @@ module Public
|
|||||||
|
|
||||||
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
|
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]
|
other_intervals = intervals - [active_interval]
|
||||||
if other_intervals.empty?
|
if other_intervals.empty?
|
||||||
label = "Piano attuale — #{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
|
label = "Piano attuale — #{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
|
||||||
return { kind: :current, label: label }
|
return { kind: :current, label: label }
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
if current_slug == "free" || !stripe_subscription_active
|
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
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -37,14 +52,13 @@ module Public
|
|||||||
public_club_checkout_path(club, plan: plan.slug, interval: interval)
|
public_club_checkout_path(club, plan: plan.slug, interval: interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
def plan_interval_button_label(plan, interval, kind: :checkout)
|
def plan_interval_button_label(plan, interval, kind: :checkout, **)
|
||||||
price = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
btn_kind = kind == :checkout_options ? :checkout : :change
|
||||||
case kind
|
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
|
||||||
when :change, :interval_switch
|
|
||||||
"Passa a #{plan.name} — #{price}"
|
|
||||||
else
|
|
||||||
"Attiva #{plan.name} — #{price}"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def billing_plan_change_info_lines(subscription: nil)
|
||||||
|
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
|
||||||
end
|
end
|
||||||
|
|
||||||
def stripe_subscription_active?(subscription)
|
def stripe_subscription_active?(subscription)
|
||||||
@@ -55,7 +69,7 @@ module Public
|
|||||||
Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval)
|
Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval)
|
||||||
end
|
end
|
||||||
|
|
||||||
def plan_interval_button_class(interval)
|
def plan_interval_button_class(interval, **)
|
||||||
if interval.to_s == "yearly"
|
if interval.to_s == "yearly"
|
||||||
"btn btn-primary plan-interval-btn plan-interval-btn--yearly"
|
"btn btn-primary plan-interval-btn plan-interval-btn--yearly"
|
||||||
else
|
else
|
||||||
@@ -77,5 +91,6 @@ module Public
|
|||||||
|
|
||||||
"Mancano: #{missing.join(", ")}."
|
"Mancano: #{missing.join(", ")}."
|
||||||
end
|
end
|
||||||
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ class Subscription < ApplicationRecord
|
|||||||
|
|
||||||
belongs_to :club
|
belongs_to :club
|
||||||
belongs_to :plan
|
belongs_to :plan
|
||||||
|
belongs_to :pending_plan, class_name: "Plan", optional: true
|
||||||
|
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :club_id, uniqueness: true
|
validates :club_id, uniqueness: true
|
||||||
@@ -25,4 +26,8 @@ class Subscription < ApplicationRecord
|
|||||||
def free?
|
def free?
|
||||||
plan.slug == "free"
|
plan.slug == "free"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def plan_change_pending?
|
||||||
|
pending_plan_id.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
76
backend/app/services/billing/stripe/apply_pending_plan.rb
Normal file
76
backend/app/services/billing/stripe/apply_pending_plan.rb
Normal file
@@ -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
|
||||||
@@ -11,37 +11,88 @@ module Billing
|
|||||||
@club = club
|
@club = club
|
||||||
@plan_slug = plan_slug.to_s
|
@plan_slug = plan_slug.to_s
|
||||||
@interval = interval
|
@interval = interval
|
||||||
|
@sub = club.subscription
|
||||||
end
|
end
|
||||||
|
|
||||||
def call
|
def call
|
||||||
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||||
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
|
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)
|
interval = resolve_interval(@sub)
|
||||||
if sub.plan.slug == @plan_slug && sub.billing_interval == interval
|
if @sub.plan.slug == @plan_slug && @sub.billing_interval == interval && !@sub.pending_plan_id?
|
||||||
raise "Sei già su questo piano e intervallo di fatturazione"
|
raise "Sei già su questo piano e intervallo di fatturazione"
|
||||||
end
|
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
|
item_id = stripe_sub.items.data.first.id
|
||||||
|
|
||||||
updated = ::Stripe::Subscription.update(
|
updated = ::Stripe::Subscription.update(
|
||||||
stripe_sub.id,
|
stripe_sub.id,
|
||||||
items: [{ id: item_id, price: price_id }],
|
items: [{ id: item_id, price: price_id }],
|
||||||
metadata: { club_id: @club.id, plan_slug: @plan_slug, billing_interval: interval },
|
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)
|
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
|
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)
|
def resolve_interval(sub)
|
||||||
return PriceCatalog.normalize_interval(@interval) if @interval.present?
|
return PriceCatalog.normalize_interval(@interval) if @interval.present?
|
||||||
@@ -61,7 +112,10 @@ module Billing
|
|||||||
current_period_start: period_start,
|
current_period_start: period_start,
|
||||||
current_period_end: period_end,
|
current_period_end: period_end,
|
||||||
cancel_at_period_end: stripe_sub.cancel_at_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
|
end
|
||||||
|
|||||||
22
backend/app/services/billing/stripe/change_plan_result.rb
Normal file
22
backend/app/services/billing/stripe/change_plan_result.rb
Normal file
@@ -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
|
||||||
@@ -69,6 +69,10 @@ module Billing
|
|||||||
infer_interval_from_price(stripe_sub) ||
|
infer_interval_from_price(stripe_sub) ||
|
||||||
PriceCatalog::DEFAULT_INTERVAL
|
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(
|
Billing::AssignPlan.call(
|
||||||
club: club,
|
club: club,
|
||||||
plan_slug: plan.slug,
|
plan_slug: plan.slug,
|
||||||
@@ -79,7 +83,10 @@ module Billing
|
|||||||
current_period_start: period_start,
|
current_period_start: period_start,
|
||||||
current_period_end: period_end,
|
current_period_end: period_end,
|
||||||
cancel_at_period_end: stripe_sub.cancel_at_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
|
end
|
||||||
|
|||||||
33
backend/app/services/billing/stripe/plan_change_kind.rb
Normal file
33
backend/app/services/billing/stripe/plan_change_kind.rb
Normal file
@@ -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
|
||||||
60
backend/app/services/billing/stripe/plan_change_messages.rb
Normal file
60
backend/app/services/billing/stripe/plan_change_messages.rb
Normal file
@@ -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
|
||||||
80
backend/app/services/billing/stripe/schedule_downgrade.rb
Normal file
80
backend/app/services/billing/stripe/schedule_downgrade.rb
Normal file
@@ -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
|
||||||
@@ -14,7 +14,11 @@ module Billing
|
|||||||
when "checkout.session.completed"
|
when "checkout.session.completed"
|
||||||
CheckoutSync.from_session(@event.data.object)
|
CheckoutSync.from_session(@event.data.object)
|
||||||
when "customer.subscription.updated", "customer.subscription.created"
|
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"
|
when "customer.subscription.deleted"
|
||||||
downgrade_to_free(@event.data.object)
|
downgrade_to_free(@event.data.object)
|
||||||
when "invoice.payment_failed"
|
when "invoice.payment_failed"
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ module Youtube
|
|||||||
PURPOSE = :youtube_oauth_connect
|
PURPOSE = :youtube_oauth_connect
|
||||||
|
|
||||||
class << self
|
class << self
|
||||||
def for_team(team_id:, user_id:)
|
def for_team(team_id:, user_id:, return_app: false)
|
||||||
verifier.generate([team_id.to_s, user_id.to_s], expires_in: 1.hour)
|
payload = [team_id.to_s, user_id.to_s]
|
||||||
|
payload << "app" if return_app
|
||||||
|
verifier.generate(payload, expires_in: 1.hour)
|
||||||
end
|
end
|
||||||
|
|
||||||
def for_platform(admin_id:)
|
def for_platform(admin_id:)
|
||||||
@@ -17,8 +19,13 @@ module Youtube
|
|||||||
when Array
|
when Array
|
||||||
if payload.first == "platform"
|
if payload.first == "platform"
|
||||||
{ kind: :platform, admin_id: payload[1] }
|
{ kind: :platform, admin_id: payload[1] }
|
||||||
elsif payload.size == 2
|
elsif payload.size >= 2
|
||||||
{ kind: :team, team_id: payload[0], user_id: payload[1] }
|
{
|
||||||
|
kind: :team,
|
||||||
|
team_id: payload[0],
|
||||||
|
user_id: payload[1],
|
||||||
|
return_app: payload[2] == "app"
|
||||||
|
}
|
||||||
else
|
else
|
||||||
raise ArgumentError, "stato OAuth non valido"
|
raise ArgumentError, "stato OAuth non valido"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %>
|
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %>
|
||||||
|
|
||||||
<%= render "shared/stripe_secure_payment" %>
|
<%= 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/plan_cards", show_stripe_portal: false %>
|
||||||
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
|
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,17 @@
|
|||||||
<div class="card" style="max-width:480px">
|
<div class="card" style="max-width:480px">
|
||||||
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
||||||
<p>Sei stato aggiunto come <strong>responsabile trasmissione</strong> (<%= @invitation.email %>).</p>
|
<p>Sei stato aggiunto come <strong>responsabile trasmissione</strong> (<%= @invitation.email %>).</p>
|
||||||
|
<p class="muted" style="font-size:0.9rem;margin-bottom:16px">
|
||||||
|
Nell’app Match Live TV accedi con <strong><%= @invitation.email %></strong>, accetta l’invito e collega il tuo canale YouTube.
|
||||||
|
</p>
|
||||||
<% if logged_in? %>
|
<% if logged_in? %>
|
||||||
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<p><%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.</p>
|
<p><%= link_to "Accedi", public_login_path %> o <%= link_to "registrati", public_signup_path %> con <%= @invitation.email %>.</p>
|
||||||
<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
<%= button_to "Accetta (se già loggato)", public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
<p style="margin-top:16px;font-size:0.88rem;color:#888">
|
||||||
|
App mobile:
|
||||||
|
<a href="matchlivetv://join/<%= @token %>">Apri invito nell’app</a>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div class="plans">
|
<div class="plans">
|
||||||
<% @plans.each do |plan| %>
|
<% @plans.each do |plan| %>
|
||||||
<div class="plan-card">
|
<div class="plan-card">
|
||||||
<h3><%= plan.name %></h3>
|
<%= render "shared/plan_title", plan: plan %>
|
||||||
<% if plan.slug == "free" %>
|
<% if plan.slug == "free" %>
|
||||||
<div class="plan-price">€0</div>
|
<div class="plan-price">€0</div>
|
||||||
<% elsif plan.slug == "premium_light" %>
|
<% elsif plan.slug == "premium_light" %>
|
||||||
@@ -27,7 +27,6 @@
|
|||||||
<% if plan.slug == @entitlements.plan.slug %>
|
<% if plan.slug == @entitlements.plan.slug %>
|
||||||
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
||||||
<% elsif plan.slug == "free" %>
|
<% elsif plan.slug == "free" %>
|
||||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
|
||||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||||
<%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %>
|
<%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %>
|
||||||
<% else %>
|
<% else %>
|
||||||
|
|||||||
@@ -16,8 +16,20 @@
|
|||||||
<strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %></strong>,
|
<strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %></strong>,
|
||||||
poi piano Free.
|
poi piano Free.
|
||||||
</span>
|
</span>
|
||||||
|
<% elsif subscription.plan_change_pending? && subscription.pending_plan.present? %>
|
||||||
|
<span style="color:#ffb74d">
|
||||||
|
Dal <strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end) : "prossimo rinnovo" %></strong>
|
||||||
|
passerai a <strong><%= subscription.pending_plan.name %></strong>
|
||||||
|
<% 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 <strong><%= current_plan.name %></strong>.
|
||||||
|
</span>
|
||||||
<% elsif subscription.current_period_end.present? %>
|
<% elsif subscription.current_period_end.present? %>
|
||||||
Prossimo rinnovo: <strong><%= l_local(subscription.current_period_end) %></strong>
|
Prossimo rinnovo: <strong><%= l_local(subscription.current_period_end) %></strong>
|
||||||
|
<% if subscription.billing_interval.present? %>
|
||||||
|
· <%= Billing::Stripe::PriceCatalog.label(plan_slug: current_plan.slug, interval: subscription.billing_interval) %>
|
||||||
|
<% end %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -12,10 +12,11 @@
|
|||||||
current_slug: current_slug,
|
current_slug: current_slug,
|
||||||
target_plan: plan,
|
target_plan: plan,
|
||||||
stripe_subscription_active: stripe_subscription_active?(subscription),
|
stripe_subscription_active: stripe_subscription_active?(subscription),
|
||||||
current_interval: current_interval
|
current_interval: current_interval,
|
||||||
|
subscription: subscription
|
||||||
) : nil %>
|
) : nil %>
|
||||||
<div class="plan-card">
|
<div class="plan-card">
|
||||||
<h3><%= plan.name %></h3>
|
<%= render "shared/plan_title", plan: plan %>
|
||||||
<% if plan.slug == "free" %>
|
<% if plan.slug == "free" %>
|
||||||
<div class="plan-price">€0</div>
|
<div class="plan-price">€0</div>
|
||||||
<% elsif plan.slug == "premium_light" %>
|
<% elsif plan.slug == "premium_light" %>
|
||||||
@@ -39,7 +40,8 @@
|
|||||||
<% action_kind = action[:kind] %>
|
<% action_kind = action[:kind] %>
|
||||||
<% if action_kind == :current %>
|
<% if action_kind == :current %>
|
||||||
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
|
||||||
<% elsif action_kind == :contact || action_kind == :disabled %>
|
<% elsif action_kind == :none %>
|
||||||
|
<% elsif action_kind.in?(%i[contact disabled]) %>
|
||||||
<p class="plan-action-hint"><%= action[:label] %></p>
|
<p class="plan-action-hint"><%= action[:label] %></p>
|
||||||
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %>
|
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %>
|
||||||
<% if billing_profile_blocks_premium?(club) %>
|
<% if billing_profile_blocks_premium?(club) %>
|
||||||
@@ -52,16 +54,13 @@
|
|||||||
<% else %>
|
<% else %>
|
||||||
<div class="plan-interval-actions">
|
<div class="plan-interval-actions">
|
||||||
<% plan_intervals_for_display(action[:intervals]).each do |interval| %>
|
<% 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),
|
<%= link_to plan_interval_button_label(plan, interval, kind: btn_kind),
|
||||||
plan_interval_checkout_path(club, plan, interval),
|
plan_interval_checkout_path(club, plan, interval),
|
||||||
class: plan_interval_button_class(interval) %>
|
class: plan_interval_button_class(interval) %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% if action_kind == :interval_switch && action[:current_interval].present? %>
|
|
||||||
<p class="plan-action-hint">Fatturazione attuale: <%= Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: action[:current_interval]) %></p>
|
|
||||||
<% end %>
|
|
||||||
<% end %>
|
<% end %>
|
||||||
<% elsif plan.slug == "free" %>
|
<% elsif plan.slug == "free" %>
|
||||||
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
|
||||||
|
|||||||
17
backend/app/views/shared/_plan_change_info.html.erb
Normal file
17
backend/app/views/shared/_plan_change_info.html.erb
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<%# locals: (subscription: nil) %>
|
||||||
|
<% lines = billing_plan_change_info_lines(subscription: subscription) %>
|
||||||
|
<div class="plan-change-info-wrap">
|
||||||
|
<details class="plan-change-info">
|
||||||
|
<summary class="plan-change-info__trigger" aria-label="Info cambio piano e fatturazione" title="Info cambio piano e fatturazione">
|
||||||
|
<i class="fa-solid fa-circle-info" aria-hidden="true"></i>
|
||||||
|
</summary>
|
||||||
|
<div class="plan-change-info__box" role="note">
|
||||||
|
<p class="plan-change-info__title">Cambio piano e fatturazione</p>
|
||||||
|
<ul>
|
||||||
|
<% lines.each do |line| %>
|
||||||
|
<li><%= line %></li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
5
backend/app/views/shared/_plan_title.html.erb
Normal file
5
backend/app/views/shared/_plan_title.html.erb
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<%# locals: (plan:) %>
|
||||||
|
<h3 class="plan-card__title">
|
||||||
|
<i class="<%= plan_icon_class(plan) %>" aria-hidden="true"></i>
|
||||||
|
<span><%= plan.name %></span>
|
||||||
|
</h3>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<%# locals: (club:, subscription:) %>
|
<%# locals: (club:, subscription:) %>
|
||||||
<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %>
|
<% return unless subscription&.premium? && subscription.stripe_subscription_id.present? && MatchLiveTv.stripe_enabled? %>
|
||||||
|
|
||||||
<section class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px">
|
<section id="subscription-cancel" class="subscription-cancel" style="margin-top:24px;padding:16px;border:1px solid #333;border-radius:8px">
|
||||||
<% if subscription.cancel_at_period_end? %>
|
<% if subscription.cancel_at_period_end? %>
|
||||||
<p style="color:#e53935;margin:0 0 8px">
|
<p style="color:#e53935;margin:0 0 8px">
|
||||||
<strong>Disdetta programmata.</strong>
|
<strong>Disdetta programmata.</strong>
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ Rails.application.routes.draw do
|
|||||||
post "auth/refresh", to: "auth#refresh"
|
post "auth/refresh", to: "auth#refresh"
|
||||||
get "auth/me", to: "auth#me"
|
get "auth/me", to: "auth#me"
|
||||||
|
|
||||||
|
get "invitations/:token", to: "invitations#show"
|
||||||
|
post "invitations/:token/accept", to: "invitations#accept"
|
||||||
|
|
||||||
resources :teams do
|
resources :teams do
|
||||||
member do
|
member do
|
||||||
post :members, action: :add_member
|
post :members, action: :add_member
|
||||||
|
|||||||
@@ -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
|
||||||
7
backend/db/schema.rb
generated
7
backend/db/schema.rb
generated
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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.datetime "updated_at", null: false
|
||||||
t.uuid "club_id", null: false
|
t.uuid "club_id", null: false
|
||||||
t.string "billing_interval"
|
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 ["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 ["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)"
|
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
||||||
end
|
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 "stream_sessions", "users"
|
||||||
add_foreign_key "subscriptions", "clubs"
|
add_foreign_key "subscriptions", "clubs"
|
||||||
add_foreign_key "subscriptions", "plans"
|
add_foreign_key "subscriptions", "plans"
|
||||||
|
add_foreign_key "subscriptions", "plans", column: "pending_plan_id"
|
||||||
add_foreign_key "team_invitations", "teams"
|
add_foreign_key "team_invitations", "teams"
|
||||||
add_foreign_key "team_roster_members", "teams"
|
add_foreign_key "team_roster_members", "teams"
|
||||||
add_foreign_key "teams", "clubs"
|
add_foreign_key "teams", "clubs"
|
||||||
|
|||||||
@@ -943,7 +943,31 @@ body.nav-menu-open { overflow: hidden; }
|
|||||||
padding: 24px;
|
padding: 24px;
|
||||||
box-sizing: border-box;
|
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-price { font-size: 1.8rem; font-weight: 800; margin: 12px 0; }
|
||||||
.plan-card ul {
|
.plan-card ul {
|
||||||
flex: 1 1 auto;
|
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;
|
font-size: 0.85rem;
|
||||||
margin: 8px 0 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,4 +67,13 @@ RSpec.describe Public::BillingHelper, type: :helper do
|
|||||||
it "mostra prima il pulsante annuale" do
|
it "mostra prima il pulsante annuale" do
|
||||||
expect(helper.plan_intervals_for_display(%w[monthly yearly])).to eq(%w[yearly monthly])
|
expect(helper.plan_intervals_for_display(%w[monthly yearly])).to eq(%w[yearly monthly])
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
54
backend/spec/requests/api/v1/invitations_spec.rb
Normal file
54
backend/spec/requests/api/v1/invitations_spec.rb
Normal file
@@ -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
|
||||||
@@ -11,48 +11,69 @@ RSpec.describe Billing::Stripe::ChangePlan do
|
|||||||
status: "active",
|
status: "active",
|
||||||
stripe_attrs: {
|
stripe_attrs: {
|
||||||
stripe_customer_id: "cus_test",
|
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_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_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_monthly_price_id).and_return("price_full_m")
|
||||||
|
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "aggiorna Stripe e il piano locale verso premium_full" do
|
it "upgrade: always_invoice e addebito immediato" do
|
||||||
stripe_item = double(id: "si_test")
|
stripe_item = double(id: "si_test", price: double(id: "price_light_m"))
|
||||||
stripe_sub = double(
|
stripe_sub = double(
|
||||||
id: "sub_test_123",
|
id: "sub_test_123",
|
||||||
customer: "cus_test",
|
customer: "cus_test",
|
||||||
status: "active",
|
status: "active",
|
||||||
current_period_start: Time.current.to_i,
|
|
||||||
current_period_end: 1.year.from_now.to_i,
|
|
||||||
cancel_at_period_end: false,
|
cancel_at_period_end: false,
|
||||||
items: double(data: [stripe_item])
|
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(: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(
|
expect(Stripe::Subscription).to have_received(:update).with(
|
||||||
"sub_test_123",
|
"sub_test_123",
|
||||||
hash_including(
|
hash_including(proration_behavior: "always_invoice", payment_behavior: "error_if_incomplete")
|
||||||
items: [{ id: "si_test", price: "price_full_y" }],
|
|
||||||
metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" }
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
expect(result).to be_upgrade
|
||||||
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
||||||
end
|
end
|
||||||
|
|
||||||
it "rifiuta il cambio sullo stesso piano e intervallo" do
|
it "downgrade: programma ScheduleDowngrade senza cambiare piano subito" do
|
||||||
club.subscription.update!(billing_interval: "yearly")
|
Billing::AssignPlan.call(
|
||||||
expect {
|
club: club,
|
||||||
described_class.call(club: club, plan_slug: "premium_light", interval: "yearly")
|
plan_slug: "premium_full",
|
||||||
}.to raise_error(/già su questo piano/)
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -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
|
||||||
59
docs/APP_YOUTUBE_LIVE.md
Normal file
59
docs/APP_YOUTUBE_LIVE.md
Normal file
@@ -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 |
|
||||||
@@ -34,6 +34,18 @@
|
|||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="https" android:host="www.matchlivetv.it" android:pathPrefix="/join"/>
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="matchlivetv"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<service
|
<service
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/login',
|
path: '/login',
|
||||||
builder: (context, state) => const LoginScreen(),
|
builder: (context, state) => LoginScreen(
|
||||||
|
inviteToken: state.uri.queryParameters['invite'],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/matches',
|
path: '/matches',
|
||||||
|
|||||||
77
mobile/lib/core/deep_link_listener.dart
Normal file
77
mobile/lib/core/deep_link_listener.dart
Normal file
@@ -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<DeepLinkListener> createState() => _DeepLinkListenerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DeepLinkListenerState extends ConsumerState<DeepLinkListener> {
|
||||||
|
final _appLinks = AppLinks();
|
||||||
|
StreamSubscription<Uri>? _sub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_handleInitialLink();
|
||||||
|
_sub = _appLinks.uriLinkStream.listen(_onUri, onError: (_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleInitialLink() async {
|
||||||
|
final uri = await _appLinks.getInitialLink();
|
||||||
|
if (uri != null) await _onUri(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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;
|
||||||
|
}
|
||||||
20
mobile/lib/core/invite_storage.dart
Normal file
20
mobile/lib/core/invite_storage.dart
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class InviteStorage {
|
||||||
|
static const _keyToken = 'pending_invite_token';
|
||||||
|
|
||||||
|
static Future<void> saveToken(String token) async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.setString(_keyToken, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<String?> readToken() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
return prefs.getString(_keyToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> clear() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
await prefs.remove(_keyToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,19 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../../app/theme.dart';
|
import '../../app/theme.dart';
|
||||||
import '../../features/matches/matches_screen.dart';
|
import '../../core/invite_storage.dart';
|
||||||
import '../../features/matches/team_providers.dart';
|
import '../../features/matches/team_providers.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../shared/api_client.dart';
|
import '../../shared/api_client.dart';
|
||||||
|
import '../../shared/models/invite_preview.dart';
|
||||||
import '../../shared/widgets/match_live_wordmark.dart';
|
import '../../shared/widgets/match_live_wordmark.dart';
|
||||||
import '../../shared/widgets/primary_cta.dart';
|
import '../../shared/widgets/primary_cta.dart';
|
||||||
import '../../shared/widgets/screen_insets.dart';
|
import '../../shared/widgets/screen_insets.dart';
|
||||||
|
|
||||||
class LoginScreen extends ConsumerStatefulWidget {
|
class LoginScreen extends ConsumerStatefulWidget {
|
||||||
const LoginScreen({super.key});
|
const LoginScreen({super.key, this.inviteToken});
|
||||||
|
|
||||||
|
final String? inviteToken;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||||
@@ -22,9 +25,44 @@ class LoginScreen extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final _emailController = TextEditingController(text: 'coach@matchlivetv.test');
|
final _emailController = TextEditingController();
|
||||||
final _passwordController = TextEditingController(text: 'password123');
|
final _passwordController = TextEditingController(text: 'password123');
|
||||||
bool _obscure = true;
|
bool _obscure = true;
|
||||||
|
InvitePreview? _invite;
|
||||||
|
String? _inviteToken;
|
||||||
|
bool _loadingInvite = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_bootstrapInvite();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -33,6 +71,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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<void> _login() async {
|
Future<void> _login() async {
|
||||||
if (!_formKey.currentState!.validate()) return;
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
@@ -56,12 +116,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
accessToken: result.tokens.accessToken,
|
accessToken: result.tokens.accessToken,
|
||||||
refreshToken: result.tokens.refreshToken,
|
refreshToken: result.tokens.refreshToken,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final authed = ApiClient(accessToken: result.tokens.accessToken);
|
||||||
|
if (_inviteToken != null) {
|
||||||
|
await _acceptInviteIfNeeded(authed);
|
||||||
|
}
|
||||||
|
|
||||||
ref.invalidate(teamsProvider);
|
ref.invalidate(teamsProvider);
|
||||||
ref.invalidate(matchesProvider);
|
ref.invalidate(matchesProvider);
|
||||||
ref.read(authProvider.notifier).setLoading(false);
|
ref.read(authProvider.notifier).setLoading(false);
|
||||||
if (mounted) context.go('/matches');
|
if (mounted) context.go('/matches');
|
||||||
return;
|
return;
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 422) {
|
||||||
|
ref.read(authProvider.notifier).setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
final message = switch (e.type) {
|
final message = switch (e.type) {
|
||||||
DioExceptionType.connectionTimeout ||
|
DioExceptionType.connectionTimeout ||
|
||||||
DioExceptionType.receiveTimeout ||
|
DioExceptionType.receiveTimeout ||
|
||||||
@@ -94,6 +164,43 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
const Center(child: MatchLiveWordmark(showSlogan: true)),
|
const Center(child: MatchLiveWordmark(showSlogan: true)),
|
||||||
const SizedBox(height: 48),
|
const SizedBox(height: 48),
|
||||||
|
if (_loadingInvite)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.only(bottom: 16),
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: AppTheme.primaryRed),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
],
|
||||||
Text(
|
Text(
|
||||||
'ACCEDI',
|
'ACCEDI',
|
||||||
style: theme.textTheme.headlineMedium,
|
style: theme.textTheme.headlineMedium,
|
||||||
@@ -101,7 +208,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Gestisci le dirette della tua squadra',
|
_invite != null
|
||||||
|
? 'Usa l\'email dell\'invito, poi collega YouTube e vai in diretta'
|
||||||
|
: 'Gestisci le dirette della tua squadra',
|
||||||
style: theme.textTheme.bodyMedium,
|
style: theme.textTheme.bodyMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
@@ -109,6 +218,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
TextFormField(
|
TextFormField(
|
||||||
controller: _emailController,
|
controller: _emailController,
|
||||||
keyboardType: TextInputType.emailAddress,
|
keyboardType: TextInputType.emailAddress,
|
||||||
|
readOnly: _invite != null,
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Email',
|
labelText: 'Email',
|
||||||
hintText: 'coach@squadra.it',
|
hintText: 'coach@squadra.it',
|
||||||
@@ -144,7 +254,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
|||||||
],
|
],
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
PrimaryCta(
|
PrimaryCta(
|
||||||
label: 'ACCEDI',
|
label: _invite != null ? 'ACCEDI E ACCETTA INVITO' : 'ACCEDI',
|
||||||
loading: auth.loading,
|
loading: auth.loading,
|
||||||
onPressed: _login,
|
onPressed: _login,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ import '../../providers/auth_provider.dart';
|
|||||||
import '../../shared/api_client.dart';
|
import '../../shared/api_client.dart';
|
||||||
import '../../shared/models/match.dart';
|
import '../../shared/models/match.dart';
|
||||||
import '../../shared/widgets/match_live_wordmark.dart';
|
import '../../shared/widgets/match_live_wordmark.dart';
|
||||||
import '../../shared/widgets/primary_cta.dart';
|
|
||||||
import '../../shared/widgets/screen_insets.dart';
|
import '../../shared/widgets/screen_insets.dart';
|
||||||
import 'resume_session_sheet.dart';
|
import 'resume_session_sheet.dart';
|
||||||
import 'no_team_onboarding.dart';
|
import 'no_team_onboarding.dart';
|
||||||
|
import 'new_match_sheet.dart';
|
||||||
import 'schedule_match_sheet.dart';
|
import 'schedule_match_sheet.dart';
|
||||||
|
import 'select_match_sheet.dart';
|
||||||
import 'team_picker.dart';
|
import 'team_picker.dart';
|
||||||
import 'team_providers.dart';
|
import 'team_providers.dart';
|
||||||
|
|
||||||
@@ -127,9 +128,38 @@ class MatchesScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'Le tue partite',
|
'Scegli una partita programmata o creane una nuova.',
|
||||||
style: theme.textTheme.bodyMedium,
|
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(),
|
const TeamPickerBar(),
|
||||||
if (activeMatch != null) ...[
|
if (activeMatch != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -186,19 +216,27 @@ class MatchesScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (matches.isEmpty)
|
SliverToBoxAdapter(
|
||||||
SliverFillRemaining(
|
|
||||||
hasScrollBody: false,
|
|
||||||
child: Center(
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Nessuna partita in calendario.\nTocca «Programma partita» per aggiungere data e ora.',
|
matches.isEmpty ? 'Calendario vuoto' : 'Calendario',
|
||||||
|
style: theme.textTheme.labelLarge?.copyWith(
|
||||||
|
color: AppTheme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (matches.isEmpty)
|
||||||
|
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,
|
style: theme.textTheme.bodyMedium,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
SliverList(
|
SliverList(
|
||||||
@@ -208,13 +246,7 @@ class MatchesScreen extends ConsumerWidget {
|
|||||||
return _MatchCard(
|
return _MatchCard(
|
||||||
match: match,
|
match: match,
|
||||||
dateFormat: dateFormat,
|
dateFormat: dateFormat,
|
||||||
onTap: () {
|
onTap: () => _openMatch(context, ref, match),
|
||||||
if (match.hasActiveSession) {
|
|
||||||
showResumeSessionSheet(context, ref, match: match);
|
|
||||||
} else {
|
|
||||||
context.push('/setup/${match.id}/1');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDelete: match.canResumeCamera
|
onDelete: match.canResumeCamera
|
||||||
? null
|
? null
|
||||||
: () => _deleteMatch(context, ref, match),
|
: () => _deleteMatch(context, ref, match),
|
||||||
@@ -224,24 +256,7 @@ class MatchesScreen extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Padding(
|
child: SizedBox(height: 20 + bottomInset),
|
||||||
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)'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -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<void> _pickScheduledMatch(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
List<MatchModel> 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<void> _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<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
|
Future<void> _scheduleMatch(BuildContext context, WidgetRef ref) async {
|
||||||
final team = await ref.read(activeTeamProvider.future);
|
final team = await ref.read(activeTeamProvider.future);
|
||||||
if (team == null) {
|
if (team == null) {
|
||||||
|
|||||||
105
mobile/lib/features/matches/new_match_sheet.dart
Normal file
105
mobile/lib/features/matches/new_match_sheet.dart
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../app/theme.dart';
|
||||||
|
|
||||||
|
enum NewMatchChoice { schedule, quickStart }
|
||||||
|
|
||||||
|
Future<NewMatchChoice?> showNewMatchSheet(BuildContext context) {
|
||||||
|
return showModalBottomSheet<NewMatchChoice>(
|
||||||
|
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
185
mobile/lib/features/matches/select_match_sheet.dart
Normal file
185
mobile/lib/features/matches/select_match_sheet.dart
Normal file
@@ -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<MatchModel?> showSelectMatchSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required List<MatchModel> matches,
|
||||||
|
}) {
|
||||||
|
final selectable = matches.where((m) => !m.hasActiveSession).toList();
|
||||||
|
if (selectable.isEmpty) return Future.value(null);
|
||||||
|
|
||||||
|
return showModalBottomSheet<MatchModel>(
|
||||||
|
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<MatchModel> 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import '../../app/theme.dart';
|
import '../../app/theme.dart';
|
||||||
import '../../providers/session_provider.dart';
|
import '../../providers/session_provider.dart';
|
||||||
@@ -74,16 +75,53 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _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) {
|
void _onYoutubeTap(Team? team) {
|
||||||
if (team == null) return;
|
if (team == null) return;
|
||||||
if (team.isYoutubeReady) {
|
if (team.isYoutubeReady) {
|
||||||
setState(() => _platform = 'youtube');
|
setState(() => _platform = 'youtube');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (team.canUseYoutube && team.youtubeMode == 'team' && team.canConnectYoutube) {
|
||||||
|
_connectYoutube(team);
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (team.canUseYoutube && team.youtubeMode == 'team') {
|
if (team.canUseYoutube && team.youtubeMode == 'team') {
|
||||||
showPremiumRequiredDialog(
|
showPremiumRequiredDialog(
|
||||||
context,
|
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,
|
billingUrl: team.staffManageUrl ?? team.billingUrl,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -119,7 +157,10 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
|
|||||||
if (team.youtubeMode == 'matchlivetv_light') {
|
if (team.youtubeMode == 'matchlivetv_light') {
|
||||||
return 'Canale Match Live TV (in attivazione)';
|
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) {
|
void _onExternalPremiumTap(String platform, Team? team) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:intl/date_symbol_data_local.dart';
|
|||||||
|
|
||||||
import 'app/router.dart';
|
import 'app/router.dart';
|
||||||
import 'app/theme.dart';
|
import 'app/theme.dart';
|
||||||
|
import 'core/deep_link_listener.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -28,11 +29,13 @@ class MatchLiveTvApp extends ConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final router = ref.watch(routerProvider);
|
final router = ref.watch(routerProvider);
|
||||||
|
|
||||||
return MaterialApp.router(
|
return DeepLinkListener(
|
||||||
|
child: MaterialApp.router(
|
||||||
title: 'Match Live TV',
|
title: 'Match Live TV',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.dark,
|
theme: AppTheme.dark,
|
||||||
routerConfig: router,
|
routerConfig: router,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../core/config.dart';
|
import '../core/config.dart';
|
||||||
import '../providers/auth_provider.dart';
|
import '../providers/auth_provider.dart';
|
||||||
|
import 'models/invite_preview.dart';
|
||||||
import 'models/match.dart';
|
import 'models/match.dart';
|
||||||
import 'models/recording_item.dart';
|
import 'models/recording_item.dart';
|
||||||
import 'models/stream_session.dart';
|
import 'models/stream_session.dart';
|
||||||
@@ -86,6 +87,24 @@ class ApiClient {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<InvitePreview> fetchInvitePreview(String token) async {
|
||||||
|
final response = await _dio.get<Map<String, dynamic>>('/invitations/$token');
|
||||||
|
return InvitePreview.fromJson(response.data!);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> acceptInvitation(String token) async {
|
||||||
|
final response = await _dio.post<Map<String, dynamic>>('/invitations/$token/accept');
|
||||||
|
return response.data!['message'] as String? ?? 'Invito accettato';
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> youtubeAuthorizeUrl(String teamId, {bool returnApp = true}) async {
|
||||||
|
final response = await _dio.get<Map<String, dynamic>>(
|
||||||
|
'/teams/$teamId/youtube/authorize',
|
||||||
|
queryParameters: returnApp ? {'return_app': '1'} : null,
|
||||||
|
);
|
||||||
|
return response.data!['authorization_url'] as String;
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<RecordingItem>> fetchRecordings(String teamId) async {
|
Future<List<RecordingItem>> fetchRecordings(String teamId) async {
|
||||||
final response = await _dio.get<List<dynamic>>('/teams/$teamId/recordings');
|
final response = await _dio.get<List<dynamic>>('/teams/$teamId/recordings');
|
||||||
return response.data!
|
return response.data!
|
||||||
|
|||||||
28
mobile/lib/shared/models/invite_preview.dart
Normal file
28
mobile/lib/shared/models/invite_preview.dart
Normal file
@@ -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<String, dynamic> 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?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ class Team {
|
|||||||
this.staffRole,
|
this.staffRole,
|
||||||
this.canStream = true,
|
this.canStream = true,
|
||||||
this.youtubeMode,
|
this.youtubeMode,
|
||||||
|
this.canConnectYoutube = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String id;
|
final String id;
|
||||||
@@ -60,6 +61,7 @@ class Team {
|
|||||||
final bool phoneDownloadEnabled;
|
final bool phoneDownloadEnabled;
|
||||||
final bool youtubeEnabled;
|
final bool youtubeEnabled;
|
||||||
final String? youtubeMode;
|
final String? youtubeMode;
|
||||||
|
final bool canConnectYoutube;
|
||||||
|
|
||||||
bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light');
|
bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light');
|
||||||
|
|
||||||
@@ -105,6 +107,7 @@ class Team {
|
|||||||
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
|
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
|
||||||
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
|
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
|
||||||
youtubeMode: json['youtube_mode'] as String?,
|
youtubeMode: json['youtube_mode'] as String?,
|
||||||
|
canConnectYoutube: json['can_connect_youtube'] as bool? ?? false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: match_live_tv
|
name: match_live_tv
|
||||||
description: Match Live TV - Lo streaming che non muore
|
description: Match Live TV - Lo streaming che non muore
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.1.0+2
|
version: 1.2.0+3
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.12.0
|
sdk: ^3.12.0
|
||||||
@@ -25,6 +25,7 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
url_launcher: ^6.3.1
|
url_launcher: ^6.3.1
|
||||||
share_plus: ^10.1.4
|
share_plus: ^10.1.4
|
||||||
|
app_links: ^6.4.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
Reference in New Issue
Block a user