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_mode: ent.plan.youtube_mode,
|
||||
staff_role: current_user.staff_role_for(team),
|
||||
can_stream: current_user.can_stream_for?(team)
|
||||
can_stream: current_user.can_stream_for?(team),
|
||||
can_connect_youtube: current_user.can_stream_for?(team) && ent.premium_full? && ent.youtube_enabled?
|
||||
}
|
||||
if detail
|
||||
data[:members] = team.user_teams.includes(:user).where.not(role: "owner").map do |ut|
|
||||
|
||||
@@ -2,11 +2,18 @@ module Api
|
||||
module V1
|
||||
class YoutubeController < BaseController
|
||||
def authorize
|
||||
team = current_user.manageable_teams.find(params[:team_id])
|
||||
team = current_user.streamable_teams.find { |t| t.id.to_s == params[:team_id].to_s }
|
||||
raise ActiveRecord::RecordNotFound unless team
|
||||
|
||||
team.entitlements.assert_can_connect_youtube!
|
||||
state = Youtube::OauthState.for_team(team_id: team.id, user_id: current_user.id)
|
||||
return_app = ActiveModel::Type::Boolean.new.cast(params[:return_app])
|
||||
state = Youtube::OauthState.for_team(
|
||||
team_id: team.id,
|
||||
user_id: current_user.id,
|
||||
return_app: return_app
|
||||
)
|
||||
url = Youtube::OauthUrl.build(state: state, redirect_uri: ENV.fetch("YOUTUBE_REDIRECT_URI"))
|
||||
render json: { authorization_url: url }
|
||||
render json: { authorization_url: url, return_app: return_app }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -96,16 +96,27 @@ module Public
|
||||
sub = @club.subscription
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active? &&
|
||||
sub.plan.slug == plan_slug && sub.billing_interval == interval
|
||||
sub.plan.slug == plan_slug && sub.billing_interval == interval && !sub.plan_change_pending?
|
||||
redirect_to public_club_billing_path(@club),
|
||||
notice: "Sei già su #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)})."
|
||||
return
|
||||
end
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active?
|
||||
Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
|
||||
if sub&.plan_change_pending? && sub.pending_plan&.slug == plan_slug &&
|
||||
sub.pending_billing_interval == interval
|
||||
when_label = sub.current_period_end.present? ? I18n.l(sub.current_period_end, format: :long) : "il prossimo rinnovo"
|
||||
redirect_to public_club_billing_path(@club),
|
||||
notice: "Piano aggiornato a #{target_plan.name} (#{Billing::Stripe::PriceCatalog.label(plan_slug: plan_slug, interval: interval)}). Eventuale differenza gestita da Stripe."
|
||||
notice: "Il passaggio a #{target_plan.name} è già programmato dal #{when_label}."
|
||||
return
|
||||
end
|
||||
|
||||
if sub&.stripe_subscription_id.present? && sub.active?
|
||||
result = Billing::Stripe::ChangePlan.call(club: @club, plan_slug: plan_slug, interval: interval)
|
||||
redirect_to public_club_billing_path(@club),
|
||||
notice: Billing::Stripe::PlanChangeMessages.flash_notice(
|
||||
result,
|
||||
current_plan: sub.plan
|
||||
)
|
||||
else
|
||||
url = Billing::Stripe::CheckoutSession.new(
|
||||
club: @club, user: current_user, plan_slug: plan_slug, interval: interval
|
||||
|
||||
@@ -116,7 +116,7 @@ module Public
|
||||
end
|
||||
|
||||
def youtube_connect
|
||||
require_club_owner_for_team!(@team)
|
||||
require_youtube_connect_access!(@team)
|
||||
@team.entitlements.assert_can_connect_youtube!
|
||||
|
||||
if ENV["YOUTUBE_CLIENT_ID"].blank?
|
||||
@@ -124,7 +124,12 @@ module Public
|
||||
return
|
||||
end
|
||||
|
||||
state = Youtube::OauthState.for_team(team_id: @team.id, user_id: current_user.id)
|
||||
return_app = params[:return_app] == "1"
|
||||
state = Youtube::OauthState.for_team(
|
||||
team_id: @team.id,
|
||||
user_id: current_user.id,
|
||||
return_app: return_app
|
||||
)
|
||||
redirect_to Youtube::OauthUrl.build(state: state), allow_other_host: true
|
||||
rescue Teams::EntitlementError => e
|
||||
redirect_to public_team_details_path(@team), alert: e.message
|
||||
@@ -138,6 +143,13 @@ module Public
|
||||
|
||||
private
|
||||
|
||||
def require_youtube_connect_access!(team)
|
||||
return if team.club&.owned_by?(current_user)
|
||||
return if current_user.can_stream_for?(team)
|
||||
|
||||
redirect_to public_team_details_path(team), alert: "Accesso non consentito"
|
||||
end
|
||||
|
||||
def load_team_details!
|
||||
@club = @team.club
|
||||
@can_manage = @club.owned_by?(current_user)
|
||||
|
||||
@@ -13,7 +13,11 @@ class YoutubeOauthCallbackController < ActionController::Base
|
||||
end
|
||||
|
||||
user = User.find(ctx[:user_id])
|
||||
team = user.manageable_teams.find(ctx[:team_id])
|
||||
team = Team.find(ctx[:team_id])
|
||||
unless user.can_stream_for?(team) || team.club&.owned_by?(user)
|
||||
return redirect_to_oauth_error("Non autorizzato a collegare YouTube per questa squadra")
|
||||
end
|
||||
|
||||
cred = team.youtube_credential || team.build_youtube_credential
|
||||
cred.update!(
|
||||
access_token: tokens[:access_token],
|
||||
@@ -22,7 +26,12 @@ class YoutubeOauthCallbackController < ActionController::Base
|
||||
channel_id: tokens[:channel_id],
|
||||
channel_title: tokens[:channel_title]
|
||||
)
|
||||
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
|
||||
|
||||
if ctx[:return_app]
|
||||
redirect_to "matchlivetv://youtube-connected?team_id=#{team.id}", allow_other_host: true
|
||||
else
|
||||
redirect_to "#{public_base_url}/teams/#{team.id}/dettagli?youtube=connected", allow_other_host: true
|
||||
end
|
||||
rescue ArgumentError, Youtube::BroadcastService::Error => e
|
||||
redirect_to_oauth_error(e.message)
|
||||
end
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
module ApplicationHelper
|
||||
PLAN_ICONS = {
|
||||
"free" => "fa-solid fa-seedling",
|
||||
"premium_light" => "fa-solid fa-rocket",
|
||||
"premium_full" => "fa-solid fa-crown"
|
||||
}.freeze
|
||||
|
||||
def plan_icon_class(plan)
|
||||
PLAN_ICONS[plan.slug] || "fa-solid fa-circle"
|
||||
end
|
||||
|
||||
# Data/ora nel fuso dell'app (Europe/Rome) e nella lingua del sito.
|
||||
def l_local(date_or_time, format: :long)
|
||||
return nil if date_or_time.blank?
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
module Public
|
||||
module BillingHelper
|
||||
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil)
|
||||
def plan_billing_action(current_slug:, target_plan:, stripe_subscription_active:, current_interval: nil, subscription: nil)
|
||||
if target_plan.slug == "free"
|
||||
return { kind: :contact, label: "Contatta il supporto per downgrade." } unless current_slug == "free"
|
||||
return { kind: :current, label: "Piano attuale" } if current_slug == "free"
|
||||
return { kind: :none } if stripe_subscription_active
|
||||
|
||||
return { kind: :current, label: "Piano attuale" }
|
||||
return { kind: :contact, label: "Per passare al piano Free, contatta il supporto." }
|
||||
end
|
||||
|
||||
unless MatchLiveTv.stripe_enabled?
|
||||
@@ -16,20 +17,34 @@ module Public
|
||||
|
||||
active_interval = current_interval.presence || Billing::Stripe::PriceCatalog::DEFAULT_INTERVAL
|
||||
|
||||
if current_slug == target_plan.slug && stripe_subscription_active
|
||||
if current_slug == target_plan.slug && stripe_subscription_active && !subscription&.plan_change_pending?
|
||||
other_intervals = intervals - [active_interval]
|
||||
if other_intervals.empty?
|
||||
label = "Piano attuale — #{Billing::Stripe::PriceCatalog.label(plan_slug: target_plan.slug, interval: active_interval)}"
|
||||
return { kind: :current, label: label }
|
||||
end
|
||||
|
||||
return { kind: :interval_switch, plan: target_plan, intervals: other_intervals, current_interval: active_interval }
|
||||
return {
|
||||
kind: :interval_switch,
|
||||
plan: target_plan,
|
||||
intervals: other_intervals,
|
||||
current_interval: active_interval,
|
||||
current_slug: current_slug,
|
||||
subscription: subscription
|
||||
}
|
||||
end
|
||||
|
||||
if current_slug == "free" || !stripe_subscription_active
|
||||
{ kind: :checkout_options, plan: target_plan, intervals: intervals }
|
||||
{ kind: :checkout_options, plan: target_plan, intervals: intervals, subscription: subscription }
|
||||
else
|
||||
{ kind: :change_options, plan: target_plan, intervals: intervals }
|
||||
{
|
||||
kind: :change_options,
|
||||
plan: target_plan,
|
||||
intervals: intervals,
|
||||
current_slug: current_slug,
|
||||
current_interval: active_interval,
|
||||
subscription: subscription
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -37,14 +52,13 @@ module Public
|
||||
public_club_checkout_path(club, plan: plan.slug, interval: interval)
|
||||
end
|
||||
|
||||
def plan_interval_button_label(plan, interval, kind: :checkout)
|
||||
price = Billing::Stripe::PriceCatalog.label(plan_slug: plan.slug, interval: interval)
|
||||
case kind
|
||||
when :change, :interval_switch
|
||||
"Passa a #{plan.name} — #{price}"
|
||||
else
|
||||
"Attiva #{plan.name} — #{price}"
|
||||
end
|
||||
def plan_interval_button_label(plan, interval, kind: :checkout, **)
|
||||
btn_kind = kind == :checkout_options ? :checkout : :change
|
||||
Billing::Stripe::PlanChangeMessages.button_label(plan: plan, interval: interval, kind: btn_kind)
|
||||
end
|
||||
|
||||
def billing_plan_change_info_lines(subscription: nil)
|
||||
Billing::Stripe::PlanChangeMessages.billing_info_lines(subscription: subscription)
|
||||
end
|
||||
|
||||
def stripe_subscription_active?(subscription)
|
||||
@@ -55,7 +69,7 @@ module Public
|
||||
Billing::Stripe::PriceCatalog.configured?(plan_slug: plan_slug, interval: interval)
|
||||
end
|
||||
|
||||
def plan_interval_button_class(interval)
|
||||
def plan_interval_button_class(interval, **)
|
||||
if interval.to_s == "yearly"
|
||||
"btn btn-primary plan-interval-btn plan-interval-btn--yearly"
|
||||
else
|
||||
@@ -77,5 +91,6 @@ module Public
|
||||
|
||||
"Mancano: #{missing.join(", ")}."
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
@@ -4,6 +4,7 @@ class Subscription < ApplicationRecord
|
||||
|
||||
belongs_to :club
|
||||
belongs_to :plan
|
||||
belongs_to :pending_plan, class_name: "Plan", optional: true
|
||||
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
validates :club_id, uniqueness: true
|
||||
@@ -25,4 +26,8 @@ class Subscription < ApplicationRecord
|
||||
def free?
|
||||
plan.slug == "free"
|
||||
end
|
||||
|
||||
def plan_change_pending?
|
||||
pending_plan_id.present?
|
||||
end
|
||||
end
|
||||
|
||||
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
|
||||
@plan_slug = plan_slug.to_s
|
||||
@interval = interval
|
||||
@sub = club.subscription
|
||||
end
|
||||
|
||||
def call
|
||||
raise "Stripe non configurato" unless MatchLiveTv.stripe_enabled?
|
||||
raise "Piano non valido" unless VALID_PLANS.include?(@plan_slug)
|
||||
|
||||
sub = @club.subscription
|
||||
raise "Nessun abbonamento Stripe attivo" if sub.blank? || sub.stripe_subscription_id.blank?
|
||||
raise "Nessun abbonamento Stripe attivo" if @sub.blank? || @sub.stripe_subscription_id.blank?
|
||||
|
||||
interval = resolve_interval(sub)
|
||||
if sub.plan.slug == @plan_slug && sub.billing_interval == interval
|
||||
interval = resolve_interval(@sub)
|
||||
if @sub.plan.slug == @plan_slug && @sub.billing_interval == interval && !@sub.pending_plan_id?
|
||||
raise "Sei già su questo piano e intervallo di fatturazione"
|
||||
end
|
||||
|
||||
price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: interval)
|
||||
kind = PlanChangeKind.classify(
|
||||
from_plan_slug: @sub.plan.slug,
|
||||
from_interval: @sub.billing_interval,
|
||||
to_plan_slug: @plan_slug,
|
||||
to_interval: interval
|
||||
)
|
||||
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(sub.stripe_subscription_id)
|
||||
case kind
|
||||
when :upgrade
|
||||
perform_upgrade!(interval: interval)
|
||||
when :downgrade
|
||||
ScheduleDowngrade.call(club: @club, plan_slug: @plan_slug, interval: interval)
|
||||
else
|
||||
raise "Sei già su questo piano e intervallo di fatturazione"
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def perform_upgrade!(interval:)
|
||||
release_schedule_if_any!
|
||||
|
||||
price_id = PriceCatalog.price_id(plan_slug: @plan_slug, interval: interval)
|
||||
stripe_sub = ::Stripe::Subscription.retrieve(@sub.stripe_subscription_id)
|
||||
item_id = stripe_sub.items.data.first.id
|
||||
|
||||
updated = ::Stripe::Subscription.update(
|
||||
stripe_sub.id,
|
||||
items: [{ id: item_id, price: price_id }],
|
||||
metadata: { club_id: @club.id, plan_slug: @plan_slug, billing_interval: interval },
|
||||
proration_behavior: "create_prorations"
|
||||
proration_behavior: "always_invoice",
|
||||
payment_behavior: "error_if_incomplete"
|
||||
)
|
||||
|
||||
invoice = pay_open_subscription_invoice!(updated.id)
|
||||
apply_local_plan!(updated, interval: interval)
|
||||
updated
|
||||
|
||||
ChangePlanResult.new(
|
||||
kind: :upgrade,
|
||||
plan_slug: @plan_slug,
|
||||
interval: interval,
|
||||
effective_at: Time.current,
|
||||
amount_cents: invoice&.amount_paid,
|
||||
currency: invoice&.currency,
|
||||
invoice_id: invoice&.id
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
def release_schedule_if_any!
|
||||
return if @sub.stripe_schedule_id.blank?
|
||||
|
||||
::Stripe::SubscriptionSchedule.release(@sub.stripe_schedule_id)
|
||||
rescue ::Stripe::InvalidRequestError
|
||||
nil
|
||||
ensure
|
||||
@sub.update!(stripe_schedule_id: nil, pending_plan_id: nil, pending_billing_interval: nil)
|
||||
end
|
||||
|
||||
def pay_open_subscription_invoice!(stripe_sub_id)
|
||||
invoice = ::Stripe::Invoice.list(subscription: stripe_sub_id, status: "open", limit: 1).data.first
|
||||
return nil unless invoice
|
||||
|
||||
paid = invoice.status == "paid" ? invoice : ::Stripe::Invoice.pay(invoice.id)
|
||||
RecordPayment.call(stripe_invoice: paid, club: @club) if paid.status == "paid"
|
||||
paid
|
||||
rescue ::Stripe::CardError => e
|
||||
raise ::Stripe::CardError, "Pagamento non riuscito: #{e.message}"
|
||||
end
|
||||
|
||||
def resolve_interval(sub)
|
||||
return PriceCatalog.normalize_interval(@interval) if @interval.present?
|
||||
@@ -61,7 +112,10 @@ module Billing
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||
billing_interval: interval
|
||||
billing_interval: interval,
|
||||
pending_plan_id: nil,
|
||||
pending_billing_interval: nil,
|
||||
stripe_schedule_id: nil
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
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) ||
|
||||
PriceCatalog::DEFAULT_INTERVAL
|
||||
|
||||
sub = club.subscription
|
||||
clear_pending = sub&.pending_plan_id.present? &&
|
||||
(sub.pending_plan.slug == plan.slug || infer_plan_slug_from_price(stripe_sub) == sub.pending_plan.slug)
|
||||
|
||||
Billing::AssignPlan.call(
|
||||
club: club,
|
||||
plan_slug: plan.slug,
|
||||
@@ -79,7 +83,10 @@ module Billing
|
||||
current_period_start: period_start,
|
||||
current_period_end: period_end,
|
||||
cancel_at_period_end: stripe_sub.cancel_at_period_end,
|
||||
billing_interval: billing_interval
|
||||
billing_interval: billing_interval,
|
||||
pending_plan_id: clear_pending ? nil : sub&.pending_plan_id,
|
||||
pending_billing_interval: clear_pending ? nil : sub&.pending_billing_interval,
|
||||
stripe_schedule_id: clear_pending ? nil : sub&.stripe_schedule_id
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
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"
|
||||
CheckoutSync.from_session(@event.data.object)
|
||||
when "customer.subscription.updated", "customer.subscription.created"
|
||||
CheckoutSync.from_subscription(@event.data.object)
|
||||
stripe_sub = @event.data.object
|
||||
CheckoutSync.from_subscription(stripe_sub)
|
||||
club = Club.joins(:subscription)
|
||||
.find_by(subscriptions: { stripe_subscription_id: stripe_sub.id })
|
||||
ApplyPendingPlan.call(club: club) if club
|
||||
when "customer.subscription.deleted"
|
||||
downgrade_to_free(@event.data.object)
|
||||
when "invoice.payment_failed"
|
||||
|
||||
@@ -3,8 +3,10 @@ module Youtube
|
||||
PURPOSE = :youtube_oauth_connect
|
||||
|
||||
class << self
|
||||
def for_team(team_id:, user_id:)
|
||||
verifier.generate([team_id.to_s, user_id.to_s], expires_in: 1.hour)
|
||||
def for_team(team_id:, user_id:, return_app: false)
|
||||
payload = [team_id.to_s, user_id.to_s]
|
||||
payload << "app" if return_app
|
||||
verifier.generate(payload, expires_in: 1.hour)
|
||||
end
|
||||
|
||||
def for_platform(admin_id:)
|
||||
@@ -17,8 +19,13 @@ module Youtube
|
||||
when Array
|
||||
if payload.first == "platform"
|
||||
{ kind: :platform, admin_id: payload[1] }
|
||||
elsif payload.size == 2
|
||||
{ kind: :team, team_id: payload[0], user_id: payload[1] }
|
||||
elsif payload.size >= 2
|
||||
{
|
||||
kind: :team,
|
||||
team_id: payload[0],
|
||||
user_id: payload[1],
|
||||
return_app: payload[2] == "app"
|
||||
}
|
||||
else
|
||||
raise ArgumentError, "stato OAuth non valido"
|
||||
end
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
<%= render "shared/club_subscription_status", club: @club, entitlements: @entitlements, subscription: @subscription, on_billing_page: true %>
|
||||
|
||||
<%= render "shared/stripe_secure_payment" %>
|
||||
<% if MatchLiveTv.stripe_enabled? %>
|
||||
<%= render "shared/plan_change_info", subscription: @subscription %>
|
||||
<% end %>
|
||||
<%= render "shared/plan_cards", show_stripe_portal: false %>
|
||||
<%= render "shared/subscription_cancel", club: @club, subscription: @subscription %>
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@
|
||||
<div class="card" style="max-width:480px">
|
||||
<h1>Accesso staff — <%= @invitation.team.name %></h1>
|
||||
<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? %>
|
||||
<%= button_to "Accetta invito", public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
<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" %>
|
||||
<% 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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="plans">
|
||||
<% @plans.each do |plan| %>
|
||||
<div class="plan-card">
|
||||
<h3><%= plan.name %></h3>
|
||||
<%= render "shared/plan_title", plan: plan %>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
@@ -27,7 +27,6 @@
|
||||
<% if plan.slug == @entitlements.plan.slug %>
|
||||
<span class="btn btn-secondary" style="opacity:0.7">Piano attuale</span>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<p style="color:#888;font-size:0.85rem">Contatta il supporto per downgrade.</p>
|
||||
<% elsif MatchLiveTv.stripe_enabled? %>
|
||||
<%= link_to "Gestisci abbonamento società", public_club_billing_path(@team.club), class: "btn btn-primary" %>
|
||||
<% else %>
|
||||
|
||||
@@ -16,8 +16,20 @@
|
||||
<strong><%= subscription.current_period_end.present? ? l_local(subscription.current_period_end.to_date) : "fine periodo" %></strong>,
|
||||
poi piano Free.
|
||||
</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? %>
|
||||
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 %>
|
||||
</p>
|
||||
<% end %>
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
current_slug: current_slug,
|
||||
target_plan: plan,
|
||||
stripe_subscription_active: stripe_subscription_active?(subscription),
|
||||
current_interval: current_interval
|
||||
current_interval: current_interval,
|
||||
subscription: subscription
|
||||
) : nil %>
|
||||
<div class="plan-card">
|
||||
<h3><%= plan.name %></h3>
|
||||
<%= render "shared/plan_title", plan: plan %>
|
||||
<% if plan.slug == "free" %>
|
||||
<div class="plan-price">€0</div>
|
||||
<% elsif plan.slug == "premium_light" %>
|
||||
@@ -39,7 +40,8 @@
|
||||
<% action_kind = action[:kind] %>
|
||||
<% if action_kind == :current %>
|
||||
<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>
|
||||
<% elsif action_kind == :checkout_options || action_kind == :change_options || action_kind == :interval_switch %>
|
||||
<% if billing_profile_blocks_premium?(club) %>
|
||||
@@ -52,16 +54,13 @@
|
||||
<% else %>
|
||||
<div class="plan-interval-actions">
|
||||
<% plan_intervals_for_display(action[:intervals]).each do |interval| %>
|
||||
<% btn_kind = action_kind == :checkout_options ? :checkout : :change %>
|
||||
<% btn_kind = action_kind == :checkout_options ? :checkout_options : action_kind %>
|
||||
<%= link_to plan_interval_button_label(plan, interval, kind: btn_kind),
|
||||
plan_interval_checkout_path(club, plan, interval),
|
||||
class: plan_interval_button_class(interval) %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% 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 %>
|
||||
<% elsif plan.slug == "free" %>
|
||||
<%= 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:) %>
|
||||
<% 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? %>
|
||||
<p style="color:#e53935;margin:0 0 8px">
|
||||
<strong>Disdetta programmata.</strong>
|
||||
|
||||
@@ -11,6 +11,9 @@ Rails.application.routes.draw do
|
||||
post "auth/refresh", to: "auth#refresh"
|
||||
get "auth/me", to: "auth#me"
|
||||
|
||||
get "invitations/:token", to: "invitations#show"
|
||||
post "invitations/:token/accept", to: "invitations#accept"
|
||||
|
||||
resources :teams do
|
||||
member do
|
||||
post :members, action: :add_member
|
||||
|
||||
@@ -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.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_06_02_120000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -250,7 +250,11 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||
t.datetime "updated_at", null: false
|
||||
t.uuid "club_id", null: false
|
||||
t.string "billing_interval"
|
||||
t.uuid "pending_plan_id"
|
||||
t.string "pending_billing_interval"
|
||||
t.string "stripe_schedule_id"
|
||||
t.index ["club_id"], name: "index_subscriptions_on_club_id", unique: true
|
||||
t.index ["pending_plan_id"], name: "index_subscriptions_on_pending_plan_id"
|
||||
t.index ["plan_id"], name: "index_subscriptions_on_plan_id"
|
||||
t.index ["stripe_subscription_id"], name: "index_subscriptions_on_stripe_subscription_id", unique: true, where: "(stripe_subscription_id IS NOT NULL)"
|
||||
end
|
||||
@@ -353,6 +357,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_05_31_140000) do
|
||||
add_foreign_key "stream_sessions", "users"
|
||||
add_foreign_key "subscriptions", "clubs"
|
||||
add_foreign_key "subscriptions", "plans"
|
||||
add_foreign_key "subscriptions", "plans", column: "pending_plan_id"
|
||||
add_foreign_key "team_invitations", "teams"
|
||||
add_foreign_key "team_roster_members", "teams"
|
||||
add_foreign_key "teams", "clubs"
|
||||
|
||||
@@ -943,7 +943,31 @@ body.nav-menu-open { overflow: hidden; }
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.plan-card h3 { margin: 0 0 8px; }
|
||||
.plan-card h3,
|
||||
.plan-card__title {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.plan-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
.plan-card__title i {
|
||||
flex-shrink: 0;
|
||||
width: 1.25rem;
|
||||
text-align: center;
|
||||
color: #e53935;
|
||||
}
|
||||
.plan-card__title i.fa-seedling {
|
||||
color: #81c784;
|
||||
}
|
||||
.plan-card__title i.fa-rocket {
|
||||
color: #64b5f6;
|
||||
}
|
||||
.plan-card__title i.fa-crown {
|
||||
color: #ffb74d;
|
||||
}
|
||||
.plan-price { font-size: 1.8rem; font-weight: 800; margin: 12px 0; }
|
||||
.plan-card ul {
|
||||
flex: 1 1 auto;
|
||||
@@ -1190,3 +1214,59 @@ label { display: block; margin-bottom: 6px; font-size: 0.9rem; color: #c8c8d4; f
|
||||
font-size: 0.85rem;
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
.plan-change-info-wrap {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.plan-change-info {
|
||||
display: inline-block;
|
||||
}
|
||||
.plan-change-info__trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
color: #888;
|
||||
list-style: none;
|
||||
user-select: none;
|
||||
}
|
||||
.plan-change-info__trigger:hover {
|
||||
background: #1e1e28;
|
||||
}
|
||||
.plan-change-info__trigger::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.plan-change-info__trigger i {
|
||||
color: #635bff;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.plan-change-info[open] .plan-change-info__trigger {
|
||||
color: #ddd;
|
||||
background: #1e1e28;
|
||||
}
|
||||
.plan-change-info__box {
|
||||
margin-top: 12px;
|
||||
max-width: 640px;
|
||||
padding: 14px 16px;
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a36;
|
||||
border-radius: 10px;
|
||||
color: #aaa;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.plan-change-info__title {
|
||||
margin: 0 0 10px;
|
||||
color: #ddd;
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.plan-change-info__box ul {
|
||||
margin: 0;
|
||||
padding-left: 1.2rem;
|
||||
}
|
||||
.plan-change-info__box li + li {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
@@ -67,4 +67,13 @@ RSpec.describe Public::BillingHelper, type: :helper do
|
||||
it "mostra prima il pulsante annuale" do
|
||||
expect(helper.plan_intervals_for_display(%w[monthly yearly])).to eq(%w[yearly monthly])
|
||||
end
|
||||
|
||||
it "sulla card Free non mostra testo se c'è abbonamento Stripe" do
|
||||
action = helper.plan_billing_action(
|
||||
current_slug: "premium_full",
|
||||
target_plan: Plan["free"],
|
||||
stripe_subscription_active: true
|
||||
)
|
||||
expect(action[:kind]).to eq(:none)
|
||||
end
|
||||
end
|
||||
|
||||
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",
|
||||
stripe_attrs: {
|
||||
stripe_customer_id: "cus_test",
|
||||
stripe_subscription_id: "sub_test_123"
|
||||
stripe_subscription_id: "sub_test_123",
|
||||
billing_interval: "monthly",
|
||||
current_period_end: 1.month.from_now
|
||||
}
|
||||
)
|
||||
allow(MatchLiveTv).to receive(:stripe_enabled?).and_return(true)
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_light_monthly_price_id).and_return("price_light_m")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_light_yearly_price_id).and_return("price_light_y")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_full_monthly_price_id).and_return("price_full_m")
|
||||
allow(MatchLiveTv).to receive(:stripe_premium_full_yearly_price_id).and_return("price_full_y")
|
||||
end
|
||||
|
||||
it "aggiorna Stripe e il piano locale verso premium_full" do
|
||||
stripe_item = double(id: "si_test")
|
||||
it "upgrade: always_invoice e addebito immediato" do
|
||||
stripe_item = double(id: "si_test", price: double(id: "price_light_m"))
|
||||
stripe_sub = double(
|
||||
id: "sub_test_123",
|
||||
customer: "cus_test",
|
||||
status: "active",
|
||||
current_period_start: Time.current.to_i,
|
||||
current_period_end: 1.year.from_now.to_i,
|
||||
cancel_at_period_end: false,
|
||||
items: double(data: [stripe_item])
|
||||
)
|
||||
updated_sub = stripe_sub
|
||||
allow(stripe_sub).to receive(:current_period_start).and_return(Time.current.to_i)
|
||||
allow(stripe_sub).to receive(:current_period_end).and_return(1.month.from_now.to_i)
|
||||
allow(stripe_item).to receive(:current_period_start).and_return(Time.current.to_i)
|
||||
allow(stripe_item).to receive(:current_period_end).and_return(1.month.from_now.to_i)
|
||||
|
||||
invoice = double(id: "in_up", status: "paid", amount_paid: 1500, currency: "eur")
|
||||
allow(Stripe::Subscription).to receive(:retrieve).with("sub_test_123").and_return(stripe_sub)
|
||||
allow(Stripe::Subscription).to receive(:update).and_return(updated_sub)
|
||||
allow(Stripe::Subscription).to receive(:update).and_return(stripe_sub)
|
||||
allow(Stripe::Invoice).to receive(:list).and_return(double(data: [invoice]))
|
||||
allow(Billing::Stripe::RecordPayment).to receive(:call)
|
||||
|
||||
described_class.call(club: club, plan_slug: "premium_full", interval: "yearly")
|
||||
result = described_class.call(club: club, plan_slug: "premium_full", interval: "monthly")
|
||||
|
||||
expect(Stripe::Subscription).to have_received(:update).with(
|
||||
"sub_test_123",
|
||||
hash_including(
|
||||
items: [{ id: "si_test", price: "price_full_y" }],
|
||||
metadata: { club_id: club.id, plan_slug: "premium_full", billing_interval: "yearly" }
|
||||
)
|
||||
hash_including(proration_behavior: "always_invoice", payment_behavior: "error_if_incomplete")
|
||||
)
|
||||
expect(result).to be_upgrade
|
||||
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
||||
end
|
||||
|
||||
it "rifiuta il cambio sullo stesso piano e intervallo" do
|
||||
club.subscription.update!(billing_interval: "yearly")
|
||||
expect {
|
||||
described_class.call(club: club, plan_slug: "premium_light", interval: "yearly")
|
||||
}.to raise_error(/già su questo piano/)
|
||||
it "downgrade: programma ScheduleDowngrade senza cambiare piano subito" do
|
||||
Billing::AssignPlan.call(
|
||||
club: club,
|
||||
plan_slug: "premium_full",
|
||||
status: "active",
|
||||
stripe_attrs: { stripe_subscription_id: "sub_test_123", billing_interval: "monthly" }
|
||||
)
|
||||
|
||||
allow(Billing::Stripe::ScheduleDowngrade).to receive(:call).and_return(
|
||||
Billing::Stripe::ChangePlanResult.new(
|
||||
kind: :downgrade,
|
||||
plan_slug: "premium_light",
|
||||
interval: "monthly",
|
||||
effective_at: 1.month.from_now
|
||||
)
|
||||
)
|
||||
|
||||
result = described_class.call(club: club, plan_slug: "premium_light", interval: "monthly")
|
||||
|
||||
expect(Billing::Stripe::ScheduleDowngrade).to have_received(:call)
|
||||
expect(result).to be_downgrade
|
||||
expect(club.reload.subscription.plan.slug).to eq("premium_full")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</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>
|
||||
|
||||
<service
|
||||
|
||||
@@ -52,7 +52,9 @@ final routerProvider = Provider<GoRouter>((ref) {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
builder: (context, state) => LoginScreen(
|
||||
inviteToken: state.uri.queryParameters['invite'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
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 '../../app/theme.dart';
|
||||
import '../../features/matches/matches_screen.dart';
|
||||
import '../../core/invite_storage.dart';
|
||||
import '../../features/matches/team_providers.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/models/invite_preview.dart';
|
||||
import '../../shared/widgets/match_live_wordmark.dart';
|
||||
import '../../shared/widgets/primary_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
|
||||
class LoginScreen extends ConsumerStatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
const LoginScreen({super.key, this.inviteToken});
|
||||
|
||||
final String? inviteToken;
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
@@ -22,9 +25,44 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||
|
||||
class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _emailController = TextEditingController(text: 'coach@matchlivetv.test');
|
||||
final _emailController = TextEditingController();
|
||||
final _passwordController = TextEditingController(text: 'password123');
|
||||
bool _obscure = true;
|
||||
InvitePreview? _invite;
|
||||
String? _inviteToken;
|
||||
bool _loadingInvite = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bootstrapInvite();
|
||||
}
|
||||
|
||||
Future<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
|
||||
void dispose() {
|
||||
@@ -33,6 +71,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
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 {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
@@ -56,12 +116,22 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
accessToken: result.tokens.accessToken,
|
||||
refreshToken: result.tokens.refreshToken,
|
||||
);
|
||||
|
||||
final authed = ApiClient(accessToken: result.tokens.accessToken);
|
||||
if (_inviteToken != null) {
|
||||
await _acceptInviteIfNeeded(authed);
|
||||
}
|
||||
|
||||
ref.invalidate(teamsProvider);
|
||||
ref.invalidate(matchesProvider);
|
||||
ref.read(authProvider.notifier).setLoading(false);
|
||||
if (mounted) context.go('/matches');
|
||||
return;
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 422) {
|
||||
ref.read(authProvider.notifier).setLoading(false);
|
||||
return;
|
||||
}
|
||||
final message = switch (e.type) {
|
||||
DioExceptionType.connectionTimeout ||
|
||||
DioExceptionType.receiveTimeout ||
|
||||
@@ -91,63 +161,103 @@ class _LoginScreenState extends ConsumerState<LoginScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 32),
|
||||
const Center(child: MatchLiveWordmark(showSlogan: true)),
|
||||
const SizedBox(height: 48),
|
||||
Text(
|
||||
'ACCEDI',
|
||||
style: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Gestisci le dirette della tua squadra',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'coach@squadra.it',
|
||||
const SizedBox(height: 32),
|
||||
const Center(child: MatchLiveWordmark(showSlogan: true)),
|
||||
const SizedBox(height: 48),
|
||||
if (_loadingInvite)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(bottom: 16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryRed),
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscure,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscure ? Icons.visibility : Icons.visibility_off,
|
||||
if (_invite != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surfaceElevated,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: AppTheme.primaryRed.withValues(alpha: 0.35)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Invito staff',
|
||||
style: theme.textTheme.titleMedium?.copyWith(color: AppTheme.primaryRed),
|
||||
),
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Entra in ${_invite!.teamName} come responsabile trasmissione.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Accedi con ${_invite!.email}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Inserisci la password' : null,
|
||||
),
|
||||
if (auth.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
auth.error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
Text(
|
||||
'ACCEDI',
|
||||
style: theme.textTheme.headlineMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_invite != null
|
||||
? 'Usa l\'email dell\'invito, poi collega YouTube e vai in diretta'
|
||||
: 'Gestisci le dirette della tua squadra',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
TextFormField(
|
||||
controller: _emailController,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
readOnly: _invite != null,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'coach@squadra.it',
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Inserisci l\'email' : null,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _passwordController,
|
||||
obscureText: _obscure,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Password',
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscure ? Icons.visibility : Icons.visibility_off,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
onPressed: () => setState(() => _obscure = !_obscure),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
PrimaryCta(
|
||||
label: 'ACCEDI',
|
||||
loading: auth.loading,
|
||||
onPressed: _login,
|
||||
),
|
||||
validator: (v) =>
|
||||
v == null || v.isEmpty ? 'Inserisci la password' : null,
|
||||
),
|
||||
if (auth.error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
auth.error!,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: AppTheme.primaryRed,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 32),
|
||||
PrimaryCta(
|
||||
label: _invite != null ? 'ACCEDI E ACCETTA INVITO' : 'ACCEDI',
|
||||
loading: auth.loading,
|
||||
onPressed: _login,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,11 +9,12 @@ import '../../providers/auth_provider.dart';
|
||||
import '../../shared/api_client.dart';
|
||||
import '../../shared/models/match.dart';
|
||||
import '../../shared/widgets/match_live_wordmark.dart';
|
||||
import '../../shared/widgets/primary_cta.dart';
|
||||
import '../../shared/widgets/screen_insets.dart';
|
||||
import 'resume_session_sheet.dart';
|
||||
import 'no_team_onboarding.dart';
|
||||
import 'new_match_sheet.dart';
|
||||
import 'schedule_match_sheet.dart';
|
||||
import 'select_match_sheet.dart';
|
||||
import 'team_picker.dart';
|
||||
import 'team_providers.dart';
|
||||
|
||||
@@ -127,9 +128,38 @@ class MatchesScreen extends ConsumerWidget {
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Le tue partite',
|
||||
'Scegli una partita programmata o creane una nuova.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _pickScheduledMatch(context, ref, matches),
|
||||
icon: const Icon(Icons.playlist_play),
|
||||
label: const Text('Partita\nprogrammata'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _newMatch(context, ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Nuova\npartita'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppTheme.primaryRed,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const TeamPickerBar(),
|
||||
if (activeMatch != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
@@ -186,17 +216,25 @@ class MatchesScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
child: Text(
|
||||
matches.isEmpty ? 'Calendario vuoto' : 'Calendario',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: AppTheme.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (matches.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||
child: Text(
|
||||
'Nessuna partita in calendario.\nTocca «Programma partita» per aggiungere data e ora.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
child: Text(
|
||||
'Nessuna partita ancora. Usa «Nuova partita» per programmare o avviare subito.',
|
||||
style: theme.textTheme.bodyMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -208,13 +246,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
return _MatchCard(
|
||||
match: match,
|
||||
dateFormat: dateFormat,
|
||||
onTap: () {
|
||||
if (match.hasActiveSession) {
|
||||
showResumeSessionSheet(context, ref, match: match);
|
||||
} else {
|
||||
context.push('/setup/${match.id}/1');
|
||||
}
|
||||
},
|
||||
onTap: () => _openMatch(context, ref, match),
|
||||
onDelete: match.canResumeCamera
|
||||
? null
|
||||
: () => _deleteMatch(context, ref, match),
|
||||
@@ -224,24 +256,7 @@ class MatchesScreen extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 8),
|
||||
child: PrimaryCta(
|
||||
label: 'PROGRAMMA PARTITA',
|
||||
icon: Icons.event_available,
|
||||
onPressed: () => _scheduleMatch(context, ref),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(20, 0, 20, 20 + bottomInset),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _createMatchQuick(context, ref),
|
||||
icon: const Icon(Icons.settings_outlined, size: 18),
|
||||
label: const Text('Crea e configura subito (senza orario)'),
|
||||
),
|
||||
),
|
||||
child: SizedBox(height: 20 + bottomInset),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -299,6 +314,48 @@ class MatchesScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
void _openMatch(BuildContext context, WidgetRef ref, MatchModel match) {
|
||||
if (match.hasActiveSession) {
|
||||
showResumeSessionSheet(context, ref, match: match);
|
||||
} else {
|
||||
context.push('/setup/${match.id}/1');
|
||||
}
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final team = await ref.read(activeTeamProvider.future);
|
||||
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:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../app/theme.dart';
|
||||
import '../../providers/session_provider.dart';
|
||||
@@ -74,16 +75,53 @@ class _StepTransmissionState extends ConsumerState<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) {
|
||||
if (team == null) return;
|
||||
if (team.isYoutubeReady) {
|
||||
setState(() => _platform = 'youtube');
|
||||
return;
|
||||
}
|
||||
if (team.canUseYoutube && team.youtubeMode == 'team' && team.canConnectYoutube) {
|
||||
_connectYoutube(team);
|
||||
return;
|
||||
}
|
||||
if (team.canUseYoutube && team.youtubeMode == 'team') {
|
||||
showPremiumRequiredDialog(
|
||||
context,
|
||||
message: 'Collega il canale YouTube dalla pagina Dettagli squadra sul sito, poi seleziona YouTube qui.',
|
||||
message: 'Collega il canale YouTube (Premium Full) per trasmettere sul tuo canale.',
|
||||
billingUrl: team.staffManageUrl ?? team.billingUrl,
|
||||
);
|
||||
return;
|
||||
@@ -119,7 +157,10 @@ class _StepTransmissionState extends ConsumerState<StepTransmission> {
|
||||
if (team.youtubeMode == 'matchlivetv_light') {
|
||||
return 'Canale Match Live TV (in attivazione)';
|
||||
}
|
||||
return 'Collega canale sul sito';
|
||||
if (team.canConnectYoutube) {
|
||||
return 'Tocca per collegare il tuo canale YouTube';
|
||||
}
|
||||
return 'Collega canale (Premium Full)';
|
||||
}
|
||||
|
||||
void _onExternalPremiumTap(String platform, Team? team) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:intl/date_symbol_data_local.dart';
|
||||
|
||||
import 'app/router.dart';
|
||||
import 'app/theme.dart';
|
||||
import 'core/deep_link_listener.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -28,11 +29,13 @@ class MatchLiveTvApp extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final router = ref.watch(routerProvider);
|
||||
|
||||
return MaterialApp.router(
|
||||
title: 'Match Live TV',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
return DeepLinkListener(
|
||||
child: MaterialApp.router(
|
||||
title: 'Match Live TV',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.dark,
|
||||
routerConfig: router,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/config.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import 'models/invite_preview.dart';
|
||||
import 'models/match.dart';
|
||||
import 'models/recording_item.dart';
|
||||
import 'models/stream_session.dart';
|
||||
@@ -86,6 +87,24 @@ class ApiClient {
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<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 {
|
||||
final response = await _dio.get<List<dynamic>>('/teams/$teamId/recordings');
|
||||
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.canStream = true,
|
||||
this.youtubeMode,
|
||||
this.canConnectYoutube = false,
|
||||
});
|
||||
|
||||
final String id;
|
||||
@@ -60,6 +61,7 @@ class Team {
|
||||
final bool phoneDownloadEnabled;
|
||||
final bool youtubeEnabled;
|
||||
final String? youtubeMode;
|
||||
final bool canConnectYoutube;
|
||||
|
||||
bool get canUseYoutube => youtubeEnabled && (premiumFull || youtubeMode == 'matchlivetv_light');
|
||||
|
||||
@@ -105,6 +107,7 @@ class Team {
|
||||
phoneDownloadEnabled: json['phone_download_enabled'] as bool? ?? false,
|
||||
youtubeEnabled: json['youtube_enabled'] as bool? ?? false,
|
||||
youtubeMode: json['youtube_mode'] as String?,
|
||||
canConnectYoutube: json['can_connect_youtube'] as bool? ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: match_live_tv
|
||||
description: Match Live TV - Lo streaming che non muore
|
||||
publish_to: 'none'
|
||||
version: 1.1.0+2
|
||||
version: 1.2.0+3
|
||||
|
||||
environment:
|
||||
sdk: ^3.12.0
|
||||
@@ -25,6 +25,7 @@ dependencies:
|
||||
shared_preferences: ^2.5.3
|
||||
url_launcher: ^6.3.1
|
||||
share_plus: ^10.1.4
|
||||
app_links: ^6.4.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
Reference in New Issue
Block a user