Billing Stripe, link regia mobile e staff solo trasmissione.

Aggiunge fatturazione club, pagina regia condivisibile senza account, roster squadre e rimuove la modalità controller dall'app (v1.1.0).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-29 07:23:13 +02:00
parent 4083bc5dee
commit f4b7be0f80
156 changed files with 5033 additions and 2033 deletions

View File

@@ -0,0 +1,94 @@
<%# locals: (club:, payments:, invoices:) %>
<section class="billing-documents" style="margin-top:40px">
<h2>Pagamenti e fatture</h2>
<p style="color:#888;font-size:0.9rem">
Qui trovi lo storico dei pagamenti e le fatture emesse alla società.
I pagamenti premium sono elaborati in modo sicuro tramite Stripe.
</p>
<% unless club.billing_profile_complete? %>
<div class="flash alert" style="margin:16px 0">
<strong>Dati di fatturazione incompleti.</strong>
Compila ragione sociale, indirizzo, P.IVA/CF e SDI o PEC per ricevere le fatture.
<%= link_to "Completa ora", public_club_billing_profile_path(club), class: "btn btn-secondary", style: "margin-top:10px;display:inline-block" %>
</div>
<% else %>
<p style="color:#888;font-size:0.88rem">
Intestatario: <%= club.billing_profile_summary %>
· <%= link_to "Modifica dati", public_club_billing_profile_path(club) %>
</p>
<% end %>
<% if MatchLiveTv.stripe_enabled? && club.subscription&.stripe_customer_id.present? %>
<%= button_to "Sincronizza pagamenti da Stripe",
public_club_billing_sync_payments_path(club),
class: "btn btn-secondary",
style: "margin:12px 0" %>
<% end %>
<h3 style="margin-top:28px;font-size:1.1rem">Pagamenti effettuati</h3>
<% if payments.any? %>
<table class="data billing-table">
<thead>
<tr>
<th>Data</th>
<th>Descrizione</th>
<th>Importo</th>
<th>Stato</th>
<th></th>
</tr>
</thead>
<tbody>
<% payments.each do |payment| %>
<tr>
<td><%= payment.paid_at ? l(payment.paid_at, format: :short) : "—" %></td>
<td><%= payment.description.presence || payment.plan_slug&.humanize || "Abbonamento" %></td>
<td><%= payment.formatted_amount %></td>
<td><%= payment.status %></td>
<td>
<% if payment.receipt_url.present? %>
<%= link_to "Ricevuta Stripe", payment.receipt_url, target: "_blank", rel: "noopener" %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p style="color:#888">Nessun pagamento registrato. Dopo un abbonamento premium comparirà qui (anche tramite «Sincronizza pagamenti»).</p>
<% end %>
<h3 style="margin-top:28px;font-size:1.1rem">Fatture</h3>
<% if invoices.any? %>
<table class="data billing-table">
<thead>
<tr>
<th>Numero</th>
<th>Data</th>
<th>Importo</th>
<th>Stato</th>
<th></th>
</tr>
</thead>
<tbody>
<% invoices.each do |invoice| %>
<tr>
<td><%= invoice.number %></td>
<td><%= l(invoice.issued_on) %></td>
<td><%= invoice.formatted_amount %></td>
<td><%= invoice.status %></td>
<td>
<% if invoice.downloadable? %>
<%= link_to "Scarica PDF", public_club_billing_invoice_path(club, invoice) %>
<% else %>
<span style="color:#888">PDF in preparazione</span>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>
<% else %>
<p style="color:#888">Nessuna fattura disponibile. Compariranno qui dopo lemissione.</p>
<% end %>
</section>

View File

@@ -0,0 +1,65 @@
<%# locals: (record:, show_legend: true) %>
<% if show_legend %>
<h2 class="form-section-title" style="margin-top:24px">Dati di fatturazione</h2>
<p style="color:#888;font-size:0.88rem;margin:-8px 0 16px">
Obbligatori per ricevere le fatture elettroniche (P.IVA/CF, indirizzo, SDI o PEC).
I pagamenti restano gestiti in modo sicuro da Stripe.
</p>
<% end %>
<%= label_tag "club[billing_entity_type]", "Tipo intestatario" %>
<%= select_tag "club[billing_entity_type]",
options_for_select(Club::BILLING_ENTITY_TYPES.map { |k, v| [v, k] }, record.billing_entity_type),
include_blank: false %>
<%= label_tag "club[billing_legal_name]", "Ragione sociale / nome e cognome" %>
<%= text_field_tag "club[billing_legal_name]", record.billing_legal_name, placeholder: "es. ASD Tigers Volley" %>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<%= label_tag "club[billing_vat_number]", "Partita IVA" %>
<%= text_field_tag "club[billing_vat_number]", record.billing_vat_number, placeholder: "12345678901" %>
</div>
<div>
<%= label_tag "club[billing_fiscal_code]", "Codice Fiscale" %>
<%= text_field_tag "club[billing_fiscal_code]", record.billing_fiscal_code, placeholder: "RSSMRA80A01H501U" %>
</div>
</div>
<%= label_tag "club[billing_email]", "Email fatturazione" %>
<%= email_field_tag "club[billing_email]", record.billing_email, placeholder: "amministrazione@societa.it" %>
<%= label_tag "club[billing_phone]", "Telefono (opzionale)" %>
<%= text_field_tag "club[billing_phone]", record.billing_phone %>
<%= label_tag "club[billing_address_line]", "Indirizzo" %>
<%= text_field_tag "club[billing_address_line]", record.billing_address_line, placeholder: "Via Roma 1" %>
<div style="display:grid;grid-template-columns:2fr 1fr 1fr;gap:12px">
<div>
<%= label_tag "club[billing_city]", "Città" %>
<%= text_field_tag "club[billing_city]", record.billing_city %>
</div>
<div>
<%= label_tag "club[billing_province]", "Prov." %>
<%= text_field_tag "club[billing_province]", record.billing_province, maxlength: 2, placeholder: "MI" %>
</div>
<div>
<%= label_tag "club[billing_postal_code]", "CAP" %>
<%= text_field_tag "club[billing_postal_code]", record.billing_postal_code %>
</div>
</div>
<%= label_tag "club[billing_country]", "Paese (ISO)" %>
<%= text_field_tag "club[billing_country]", record.billing_country.presence || "IT", maxlength: 2 %>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>
<%= label_tag "club[billing_recipient_code]", "Codice destinatario SDI (7 caratteri)" %>
<%= text_field_tag "club[billing_recipient_code]", record.billing_recipient_code, maxlength: 7, placeholder: "XXXXXXX" %>
</div>
<div>
<%= label_tag "club[billing_pec]", "PEC (alternativa a SDI)" %>
<%= email_field_tag "club[billing_pec]", record.billing_pec, placeholder: "pec@societa.it" %>
</div>
</div>

View File

@@ -1,5 +1,9 @@
<% record = local_assigns[:record] %>
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
<% param_key = record.model_name.param_key %>
<% primary = record.primary_color.presence || record.effective_primary_color %>
<% secondary = record.secondary_color.presence || record.effective_secondary_color %>
<% logo_src = record.effective_logo_url.presence %>
<fieldset class="branding-fields">
<legend><%= local_assigns[:legend] || "Identità visiva" %></legend>
@@ -8,26 +12,59 @@
<p class="branding-hint">Lascia vuoto per usare logo e colori della società.</p>
<% end %>
<%= label_tag "branding[logo_file]", "Logo (immagine)" %>
<%= file_field_tag "branding[logo_file]", accept: "image/png,image/jpeg,image/webp" %>
<% if record.effective_logo_url.present? %>
<p class="branding-preview">
<%= image_tag record.effective_logo_url, alt: "", width: 64, height: 64, style: "border-radius:8px" %>
</p>
<% end %>
<%= label_tag "#{record.model_name.param_key}[logo_url]", "Logo (URL alternativo)" %>
<%= text_field_tag "#{record.model_name.param_key}[logo_url]", record.logo_url, placeholder: "https://…" %>
<%= label_tag "#{record.model_name.param_key}[primary_color]", "Colore principale" %>
<div class="color-field">
<%= color_field_tag "#{record.model_name.param_key}[primary_color]", record.primary_color.presence || record.effective_primary_color %>
<span class="color-swatch" style="background:<%= record.effective_primary_color %>"></span>
<div class="branding-logo-block">
<%= label_tag "branding[logo_file]", "Logo (immagine)" %>
<div class="branding-logo-preview" data-branding-logo-preview>
<%= image_tag logo_src.presence || "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7",
alt: "Anteprima logo",
class: "branding-logo-preview__img",
data: { branding_logo_img: true },
width: 96,
height: 96,
hidden: logo_src.blank? %>
<span class="branding-logo-preview__placeholder" data-branding-logo-placeholder <%= "hidden" if logo_src.present? %>>
Nessun logo — scegli un file per lanteprima
</span>
</div>
<%= file_field_tag "branding[logo_file]", accept: "image/png,image/jpeg,image/webp", data: { branding_logo_file: true }, class: "branding-file-input" %>
</div>
<%= label_tag "#{record.model_name.param_key}[secondary_color]", "Colore secondario" %>
<div class="color-field">
<%= color_field_tag "#{record.model_name.param_key}[secondary_color]", record.secondary_color.presence || record.effective_secondary_color %>
<span class="color-swatch" style="background:<%= record.effective_secondary_color %>"></span>
<%= label_tag "#{param_key}[logo_url]", "Logo (URL alternativo)" %>
<%= text_field_tag "#{param_key}[logo_url]", record.logo_url, placeholder: "https://…", class: "branding-url-input" %>
<%= label_tag "#{param_key}[primary_color]", "Colore principale" %>
<div class="branding-color-row" data-branding-color-row="primary">
<%= color_field_tag "#{param_key}[primary_color]", primary, data: { color_picker: true }, class: "branding-color-picker", title: "Scegli colore" %>
<%= text_field_tag nil, primary, data: { color_hex: true }, class: "branding-hex-input", placeholder: "#e53935", autocomplete: "off", spellcheck: false %>
<span class="color-swatch branding-color-swatch" data-color-swatch aria-hidden="true"></span>
<div class="branding-color-presets" aria-label="Colori rapidi">
<% %w[#e53935 #1565c0 #2e7d32 #ff9800 #212121 #ffffff].each do |hex| %>
<button type="button" class="branding-preset" data-color-preset="<%= hex %>" style="background:<%= hex %>" title="<%= hex %>"></button>
<% end %>
</div>
</div>
<%= label_tag "#{param_key}[secondary_color]", "Colore secondario" %>
<div class="branding-color-row" data-branding-color-row="secondary">
<%= color_field_tag "#{param_key}[secondary_color]", secondary, data: { color_picker: true }, class: "branding-color-picker", title: "Scegli colore" %>
<%= text_field_tag nil, secondary, data: { color_hex: true }, class: "branding-hex-input", placeholder: "#ffffff", autocomplete: "off", spellcheck: false %>
<span class="color-swatch branding-color-swatch" data-color-swatch aria-hidden="true"></span>
<div class="branding-color-presets" aria-label="Colori rapidi">
<% %w[#ffffff #f5f5f5 #212121 #ffd54f #90caf9 #e53935].each do |hex| %>
<button type="button" class="branding-preset" data-color-preset="<%= hex %>" style="background:<%= hex %>" title="<%= hex %>"></button>
<% end %>
</div>
</div>
<div class="branding-live-preview" data-branding-live-preview>
<span class="branding-live-preview__label">Anteprima intestazione</span>
<div class="branding-live-preview__header" data-preview-header style="--preview-primary:<%= primary %>;--preview-secondary:<%= secondary %>">
<span class="branding-live-preview__logo" data-preview-logo>
<% if logo_src %>
<%= image_tag logo_src, alt: "", width: 40, height: 40, style: "border-radius:8px;object-fit:contain" %>
<% end %>
</span>
<span class="branding-live-preview__name"><%= record.try(:name).presence || "Nome società / squadra" %></span>
</div>
</div>
</fieldset>

View File

@@ -0,0 +1,19 @@
<%# locals: (club:, entitlements: nil, subscription: nil) %>
<% current_plan = entitlements&.plan || subscription&.plan || Plan["free"] %>
<p style="color:#aaa;margin-bottom:8px">
Società: <strong><%= club.name %></strong>
· Piano attuale: <strong><%= current_plan.name %></strong>
(valido per tutte le squadre)
· <%= link_to "Gestisci abbonamento", public_club_billing_path(club) %>
</p>
<% if subscription&.stripe_subscription_id.present? %>
<p style="color:#888;font-size:0.9rem;margin-top:0">
Stato Stripe: <strong><%= subscription.status %></strong>
<% if subscription.current_period_end.present? %>
· Rinnovo: <strong><%= l(subscription.current_period_end, format: :long) %></strong>
<% end %>
<% if subscription.cancel_at_period_end? %>
· <span style="color:#e53935">Disdetta alla scadenza</span>
<% end %>
</p>
<% end %>

View File

@@ -0,0 +1,22 @@
<%# locals: (name:, label:, value: nil, input_type: "text", required: false, autocomplete: nil, minlength: nil, id: nil) %>
<% field_id = local_assigns.fetch(:id, nil).presence || name.to_s %>
<% visible_type = local_assigns.fetch(:input_type, "text").to_s == "email" ? "email" : "text" %>
<div class="input-toggle" data-input-toggle>
<%= label_tag field_id, label %>
<div class="input-toggle__field">
<%= tag.input(
type: "password",
name: name,
id: field_id,
value: local_assigns.fetch(:value, nil),
required: local_assigns.fetch(:required, false),
autocomplete: local_assigns.fetch(:autocomplete, nil),
minlength: local_assigns.fetch(:minlength, nil),
class: "input-toggle__input",
data: { visible_type: visible_type }
) %>
<button type="button" class="input-toggle__btn" aria-label="Mostra <%= label.downcase %>" data-label-hide="Nascondi <%= label.downcase %>">
<i class="fa-regular fa-eye" aria-hidden="true"></i>
</button>
</div>
</div>

View File

@@ -10,5 +10,8 @@
<%= link_to "Privacy", public_privacy_path %> ·
<%= link_to "Termini", public_termini_path %>
</div>
<div class="site-footer__stripe">
<%= render "shared/stripe_secure_payment", compact: true %>
</div>
</div>
</footer>

View File

@@ -28,7 +28,7 @@
<% if current_user.primary_club %>
<%= link_to "Società", public_club_path(current_user.primary_club), class: "nav-link-item" %>
<% elsif current_user.manageable_teams.first %>
<%= link_to "Dashboard", public_team_dashboard_path(current_user.manageable_teams.first), class: "nav-link-item" %>
<%= link_to "Dettagli squadra", public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
<% end %>
<% end %>
<%= button_to "Esci", public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>

View File

@@ -0,0 +1,55 @@
<%# locals: (club: nil, entitlements: nil, subscription: nil, current_plan_slug: nil, show_stripe_portal: false) %>
<% club ||= @club %>
<% entitlements ||= @entitlements %>
<% subscription ||= @subscription %>
<% current_slug = local_assigns.fetch(:current_plan_slug, nil).presence || entitlements&.plan&.slug || subscription&.plan&.slug || "free" %>
<% billing_mode = club.present? %>
<div class="plans">
<% @plans.each do |plan| %>
<% action = billing_mode ? plan_billing_action(
current_slug: current_slug,
target_plan: plan,
stripe_subscription_active: stripe_subscription_active?(subscription)
) : nil %>
<div class="plan-card <%= "featured" if plan.slug == current_slug || (!billing_mode && plan.slug == "premium_light") %>">
<h3><%= plan.name %></h3>
<% if plan.slug == "free" %>
<div class="plan-price">€0</div>
<% elsif plan.slug == "premium_light" %>
<div class="plan-price" style="color:#e53935">€40<span style="font-size:1rem;font-weight:400">/anno</span></div>
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€5</strong>/mese</p>
<% else %>
<div class="plan-price">€200<span style="font-size:1rem;font-weight:400">/anno</span></div>
<p style="color:#aaa;font-size:0.9rem;margin:-8px 0 12px">oppure <strong>€20</strong>/mese</p>
<% end %>
<ul>
<li>Responsabili trasmissione: <strong><%= plan.max_staff_transmission || "illimitato" %></strong> / anno per squadra</li>
<li>Partite: <strong><%= plan.concurrent_streams_limit || "illimitate" %></strong> in contemporanea (tutta la società)</li>
<li>Replay: <%= plan.recordings_enabled? ? "#{plan.recording_days} giorni" : "no" %></li>
<li>YouTube: <% if plan.slug == "premium_light" %>Match TV Light<% elsif plan.youtube_enabled? %>canale società<% else %>no<% end %></li>
</ul>
<% if billing_mode %>
<% case action[:kind] %>
<% when :current %>
<span class="btn btn-secondary" style="opacity:0.7"><%= action[:label] %></span>
<% when :contact, :disabled %>
<p style="color:#888;font-size:0.85rem"><%= action[:label] %></p>
<% when :checkout, :change %>
<%= link_to action[:label], public_club_checkout_path(club, plan: plan.slug), class: "btn btn-primary" %>
<% end %>
<% elsif plan.slug == "free" %>
<%= link_to "Inizia gratis", public_signup_path, class: "btn btn-secondary" %>
<% else %>
<%= link_to "Registrati", public_signup_path(plan: plan.slug), class: "btn btn-primary" %>
<% end %>
</div>
<% end %>
</div>
<% if show_stripe_portal && billing_mode && subscription&.premium? && MatchLiveTv.stripe_enabled? && subscription&.stripe_customer_id.present? %>
<p style="margin-top:20px">
<%= button_to "Gestisci fatturazione su Stripe", public_club_portal_path(club), class: "btn btn-secondary" %>
<span style="color:#888;font-size:0.85rem;display:block;margin-top:8px">Cambia metodo di pagamento, scarica fatture o disdici l'abbonamento.</span>
</p>
<% end %>

View File

@@ -0,0 +1,50 @@
<% member = local_assigns[:member] %>
<% team = local_assigns[:team] %>
<% form_url = member.persisted? ? public_team_roster_member_path(team, member) : public_team_roster_members_path(team) %>
<% form_method = member.persisted? ? :patch : :post %>
<% is_player = member.category == "player" %>
<% player_role = member.role_label if member.role_label.in?(TeamRosterMember::VOLLEYBALL_PLAYER_ROLES) %>
<%= form_with url: form_url, method: form_method, multipart: true, class: "roster-member-form card roster-sidebar__form", data: { roster_form: true } do %>
<h3 class="roster-sidebar__title"><%= member.persisted? ? "Modifica scheda" : "Aggiungi persona" %></h3>
<p class="roster-sidebar__lead">Compila i campi e salva per aggiornare lorganico.</p>
<%= label_tag "team_roster_member[category]", "Gruppo" %>
<%= select_tag "team_roster_member[category]", options_for_select(roster_category_options, member.category),
data: { roster_category: true } %>
<%= label_tag "team_roster_member[full_name]", "Nome e cognome" %>
<%= text_field_tag "team_roster_member[full_name]", member.full_name, required: true, placeholder: "es. Mario Rossi" %>
<div data-roster-role-fields>
<div data-roster-role-player <%= "hidden" unless is_player %>>
<%= label_tag "team_roster_member[role_label]", "Ruolo in campo" %>
<%= select_tag "team_roster_member[role_label]", roster_player_role_options(player_role),
data: { roster_role_player: true }, disabled: !is_player %>
</div>
<div data-roster-role-other <%= "hidden" if is_player %>>
<%= label_tag "team_roster_member[role_label_other]", "Ruolo (opzionale)" %>
<%= text_field_tag "team_roster_member[role_label_other]", (member.role_label unless is_player),
placeholder: "es. Capo allenatore, Presidente, Massaggiatore",
data: { roster_role_other: true }, disabled: is_player %>
</div>
</div>
<div data-roster-jersey-row <%= "hidden" unless is_player %>>
<%= label_tag "team_roster_member[jersey_number]", "Numero maglia" %>
<%= number_field_tag "team_roster_member[jersey_number]", member.jersey_number, min: 1, max: 99,
placeholder: "es. 7", data: { roster_jersey: true }, disabled: !is_player %>
</div>
<%= label_tag "team_roster_member[bio]", "Descrizione breve" %>
<%= text_area_tag "team_roster_member[bio]", member.bio, rows: 3, placeholder: "Breve presentazione per la scheda…" %>
<%= label_tag "team_roster_member[photo_file]", "Foto" %>
<% if member.photo_url.present? %>
<div class="roster-form-photo-preview"><%= roster_person_avatar(member, team, size: 72) %></div>
<% end %>
<%= file_field_tag "team_roster_member[photo_file]", accept: "image/png,image/jpeg,image/webp" %>
<p class="branding-hint">Senza foto verrà mostrato un avatar con i colori della squadra.</p>
<%= submit_tag (member.persisted? ? "Salva scheda" : "Aggiungi all'organico"), class: "btn btn-primary" %>
<% end %>

View File

@@ -0,0 +1,29 @@
<% person = local_assigns[:person] %>
<% team = local_assigns[:team] %>
<% editable = local_assigns.fetch(:editable, false) %>
<% compact = local_assigns.fetch(:compact, false) %>
<article class="roster-card<%= " roster-card--compact" if compact %>">
<div class="roster-card__avatar">
<%= roster_person_avatar(person, team, size: 72) %>
</div>
<div class="roster-card__body">
<h3 class="roster-card__name">
<%= person.full_name %>
<% if person.category == "player" && person.jersey_number.present? %>
<span class="roster-card__number">#<%= person.jersey_number %></span>
<% end %>
</h3>
<p class="roster-card__role"><%= person.display_role %></p>
<% if person.bio.present? %>
<p class="roster-card__bio"><%= person.bio %></p>
<% end %>
</div>
<% if editable %>
<div class="roster-card__actions">
<%= link_to "Modifica", edit_public_team_roster_member_path(team, person), class: "roster-card__btn" %>
<%= button_to "Rimuovi", public_team_roster_member_path(team, person), method: :delete, class: "roster-card__btn roster-card__btn--danger",
form: { data: { turbo_confirm: "Rimuovere #{person.full_name} dall'organico?" } } %>
</div>
<% end %>
</article>

View File

@@ -0,0 +1,12 @@
<%# locals: (compact: false) %>
<div class="stripe-secure<%= " stripe-secure--compact" if local_assigns.fetch(:compact, false) %>">
<i class="fa-solid fa-lock" aria-hidden="true"></i>
<p>
<% if local_assigns.fetch(:compact, false) %>
Pagamenti premium elaborati in modo sicuro tramite <strong>Stripe</strong>.
<% else %>
I pagamenti dei piani premium sono elaborati in modo <strong>sicuro</strong> tramite
<strong>Stripe</strong> (certificazione PCI DSS). Match Live TV non memorizza i numeri completi della carta.
<% end %>
</p>
</div>