+ <%= link_to t("common.contacts"), public_contatti_path %> ·
<%= link_to t("common.support"), public_support_path %> ·
<%= link_to t("common.pricing"), public_prezzi_path %> ·
<%= link_to t("footer.live"), public_live_index_path %> ·
diff --git a/backend/app/views/shared/_site_announcements.html.erb b/backend/app/views/shared/_site_announcements.html.erb
new file mode 100644
index 0000000..d0f6f61
--- /dev/null
+++ b/backend/app/views/shared/_site_announcements.html.erb
@@ -0,0 +1,45 @@
+<% announcements = @site_announcements %>
+<% if announcements.present? %>
+
">
+ <% announcements.each do |item| %>
+
+
+
<%= item.title %>
+
<%= simple_format(item.body) %>
+ <% if item.action_url.present? %>
+
+ <%= link_to (item.action_label.presence || t("announcement.open")), item.action_url,
+ class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
+
+ <% end %>
+
+ <% if item.dismissible? %>
+
+ <% end %>
+
+ <% end %>
+
+
+<% end %>
diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml
index 1e03487..4454b35 100644
--- a/backend/config/locales/admin.de.yml
+++ b/backend/config/locales/admin.de.yml
@@ -5,6 +5,7 @@ de:
nav:
dashboard: Dashboard
ops: Ops
+ announcements: Hinweise
clubs: Vereine & Teams
billing: Abrechnung
youtube: YouTube
@@ -45,6 +46,9 @@ de:
invoice_updated: "Rechnung %{number} aktualisiert."
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
+ announcement_created: Hinweis gespeichert
+ announcement_updated: Hinweis aktualisiert
+ announcement_destroyed: Hinweis gelöscht
common:
free_plan: Free
yes: "Ja"
@@ -360,3 +364,64 @@ de:
club_recordings:
index:
title: "Replays — %{club}"
+ announcements:
+ index:
+ title: Hinweise
+ lead: Meldungen für die Apps, die öffentliche Website und den Login-Bereich — Wartung, Updates oder Mitteilungen.
+ new: Neuer Hinweis
+ table_title: Hinweise
+ none: Noch keine Hinweise. Erstelle einen, um Apps und Website zu informieren.
+ new:
+ title: Neuer Hinweis
+ edit:
+ title: Hinweis bearbeiten
+ table:
+ status: Status
+ kind: Typ
+ title: Nachricht
+ channels: Ziele
+ window: Zeitraum
+ actions:
+ edit: Bearbeiten
+ delete: Löschen
+ delete_confirm: Diesen Hinweis löschen? Er erscheint nicht mehr an den gewählten Stellen.
+ kind:
+ info: Information
+ maintenance: Wartung
+ update: App-Update
+ severity:
+ info: Info
+ warning: Warnung
+ critical: Kritisch
+ status:
+ draft: Entwurf
+ published: Veröffentlicht
+ archived: Archiviert
+ channels:
+ app: Apps (Android und iOS)
+ web_public: Öffentliche Website
+ web_private: Login-Bereich
+ window:
+ always: Immer (solange veröffentlicht)
+ from: "Ab %{time}"
+ until: "Bis %{time}"
+ form:
+ kind: Typ
+ severity: Schwere
+ status: Status
+ channels: Wo anzeigen
+ channels_hint: Eine oder mehrere Ziele wählen. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto).
+ title: Titel
+ body: Text
+ starts_at: Sichtbar ab
+ ends_at: Sichtbar bis
+ window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
+ action_url: Link (optional)
+ action_url_hint: Bei Updates öffnet eine leere URL den Store. Sonst eine https-URL.
+ action_label: Button-Text
+ dismissible: Nutzer können den Hinweis schließen
+ dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
+ submit_create: Hinweis speichern
+ submit_update: Hinweis aktualisieren
+ cancel: Abbrechen
+
diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml
index 4ffa86e..e445089 100644
--- a/backend/config/locales/admin.en.yml
+++ b/backend/config/locales/admin.en.yml
@@ -5,6 +5,7 @@ en:
nav:
dashboard: Dashboard
ops: Ops
+ announcements: Notices
clubs: Clubs & teams
billing: Billing
youtube: YouTube
@@ -45,6 +46,9 @@ en:
invoice_updated: "Invoice %{number} updated."
invoice_uploaded: "Invoice uploaded and sent to %{email}."
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
+ announcement_created: Notice saved
+ announcement_updated: Notice updated
+ announcement_destroyed: Notice deleted
common:
free_plan: Free
yes: "Yes"
@@ -360,3 +364,64 @@ en:
club_recordings:
index:
title: "Replays — %{club}"
+ announcements:
+ index:
+ title: Notices
+ lead: Messages for the apps, the public website and the logged-in area — maintenance, updates or announcements.
+ new: New notice
+ table_title: Notices
+ none: No notices yet. Create one to notify the apps and the website.
+ new:
+ title: New notice
+ edit:
+ title: Edit notice
+ table:
+ status: Status
+ kind: Type
+ title: Message
+ channels: Destinations
+ window: Window
+ actions:
+ edit: Edit
+ delete: Delete
+ delete_confirm: Delete this notice? It will no longer appear in the selected destinations.
+ kind:
+ info: Information
+ maintenance: Maintenance
+ update: App update
+ severity:
+ info: Info
+ warning: Warning
+ critical: Critical
+ status:
+ draft: Draft
+ published: Published
+ archived: Archived
+ channels:
+ app: Apps (Android and iOS)
+ web_public: Public website
+ web_private: Logged-in area
+ window:
+ always: Always (while published)
+ from: "From %{time}"
+ until: "Until %{time}"
+ form:
+ kind: Type
+ severity: Severity
+ status: Status
+ channels: Where to show it
+ channels_hint: Select one or more destinations. The logged-in area is visible after sign-in (club, teams, account).
+ title: Title
+ body: Body
+ starts_at: Visible from
+ ends_at: Visible until
+ window_hint: Leave empty to show immediately while published.
+ action_url: Link (optional)
+ action_url_hint: For updates, an empty URL opens the store. Otherwise use an https URL.
+ action_label: Button label
+ dismissible: Users can dismiss the notice
+ dismissible_hint: Uncheck for maintenance notices that should stay visible.
+ submit_create: Save notice
+ submit_update: Update notice
+ cancel: Cancel
+
diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml
index dccd50c..a1be8f0 100644
--- a/backend/config/locales/admin.es.yml
+++ b/backend/config/locales/admin.es.yml
@@ -5,6 +5,7 @@ es:
nav:
dashboard: Panel
ops: Ops
+ announcements: Avisos
clubs: Clubes y equipos
billing: Facturación
youtube: YouTube
@@ -45,6 +46,9 @@ es:
invoice_updated: "Factura %{number} actualizada."
invoice_uploaded: "Factura subida y enviada a %{email}."
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
+ announcement_created: Aviso guardado
+ announcement_updated: Aviso actualizado
+ announcement_destroyed: Aviso eliminado
common:
free_plan: Free
yes: "Sí"
@@ -360,3 +364,64 @@ es:
club_recordings:
index:
title: "Repeticiones — %{club}"
+ announcements:
+ index:
+ title: Avisos
+ lead: Mensajes hacia las apps, el sitio público y el área privada — mantenimiento, actualizaciones o comunicados.
+ new: Nuevo aviso
+ table_title: Avisos
+ none: Todavía no hay avisos. Crea uno para notificar a las apps y al sitio.
+ new:
+ title: Nuevo aviso
+ edit:
+ title: Editar aviso
+ table:
+ status: Estado
+ kind: Tipo
+ title: Mensaje
+ channels: Destinos
+ window: Ventana
+ actions:
+ edit: Editar
+ delete: Eliminar
+ delete_confirm: ¿Eliminar este aviso? Ya no se verá en los destinos elegidos.
+ kind:
+ info: Información
+ maintenance: Mantenimiento
+ update: Actualización
+ severity:
+ info: Info
+ warning: Atención
+ critical: Crítico
+ status:
+ draft: Borrador
+ published: Publicado
+ archived: Archivado
+ channels:
+ app: Apps (Android e iOS)
+ web_public: Sitio público
+ web_private: Área privada
+ window:
+ always: Siempre (mientras esté publicado)
+ from: "Desde %{time}"
+ until: "Hasta %{time}"
+ form:
+ kind: Tipo
+ severity: Gravedad
+ status: Estado
+ channels: Dónde mostrarlo
+ channels_hint: Elige uno o más destinos. El área privada se ve tras iniciar sesión (club, equipos, cuenta).
+ title: Título
+ body: Texto
+ starts_at: Visible desde
+ ends_at: Visible hasta
+ window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
+ action_url: Enlace (opcional)
+ action_url_hint: En actualizaciones, una URL vacía abre la tienda. Si no, usa una URL https.
+ action_label: Texto del botón
+ dismissible: El usuario puede cerrar el aviso
+ dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
+ submit_create: Guardar aviso
+ submit_update: Actualizar aviso
+ cancel: Cancelar
+
diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml
index 7e1bb69..48730d7 100644
--- a/backend/config/locales/admin.fr.yml
+++ b/backend/config/locales/admin.fr.yml
@@ -5,6 +5,7 @@ fr:
nav:
dashboard: Tableau de bord
ops: Ops
+ announcements: Alertes
clubs: Clubs et équipes
billing: Facturation
youtube: YouTube
@@ -45,6 +46,9 @@ fr:
invoice_updated: "Facture %{number} mise à jour."
invoice_uploaded: "Facture chargée et envoyée à %{email}."
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
+ announcement_created: Alerte enregistrée
+ announcement_updated: Alerte mise à jour
+ announcement_destroyed: Alerte supprimée
common:
free_plan: Free
yes: "Oui"
@@ -360,3 +364,64 @@ fr:
club_recordings:
index:
title: "Replays — %{club}"
+ announcements:
+ index:
+ title: Alertes
+ lead: Messages vers les apps, le site public et l'espace connecté — maintenance, mises à jour ou communications.
+ new: Nouvelle alerte
+ table_title: Alertes
+ none: Aucune alerte. Créez-en une pour notifier les applications et le site.
+ new:
+ title: Nouvelle alerte
+ edit:
+ title: Modifier l'alerte
+ table:
+ status: Statut
+ kind: Type
+ title: Message
+ channels: Destinations
+ window: Fenêtre
+ actions:
+ edit: Modifier
+ delete: Supprimer
+ delete_confirm: Supprimer cette alerte ? Elle n'apparaîtra plus aux destinations choisies.
+ kind:
+ info: Information
+ maintenance: Maintenance
+ update: Mise à jour
+ severity:
+ info: Info
+ warning: Attention
+ critical: Critique
+ status:
+ draft: Brouillon
+ published: Publié
+ archived: Archivé
+ channels:
+ app: Apps (Android et iOS)
+ web_public: Site public
+ web_private: Espace connecté
+ window:
+ always: Toujours (tant que publié)
+ from: "À partir de %{time}"
+ until: "Jusqu'à %{time}"
+ form:
+ kind: Type
+ severity: Gravité
+ status: Statut
+ channels: Où l'afficher
+ channels_hint: Choisissez une ou plusieurs destinations. L'espace connecté est visible après connexion (club, équipes, compte).
+ title: Titre
+ body: Texte
+ starts_at: Visible à partir de
+ ends_at: Visible jusqu'à
+ window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
+ action_url: Lien (facultatif)
+ action_url_hint: Pour une mise à jour, une URL vide ouvre le store. Sinon une URL https.
+ action_label: Libellé du bouton
+ dismissible: L'utilisateur peut fermer l'alerte
+ dismissible_hint: Décochez pour une maintenance qui doit rester visible.
+ submit_create: Enregistrer l'alerte
+ submit_update: Mettre à jour l'alerte
+ cancel: Annuler
+
diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml
index b5be3f0..77e9ce2 100644
--- a/backend/config/locales/admin.it.yml
+++ b/backend/config/locales/admin.it.yml
@@ -5,6 +5,7 @@ it:
nav:
dashboard: Dashboard
ops: Ops
+ announcements: Avvisi
clubs: Società e squadre
billing: Fatturazione
youtube: YouTube
@@ -49,6 +50,9 @@ it:
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
transfer_cancelled: Bonifico in attesa annullato.
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
+ announcement_created: Avviso salvato
+ announcement_updated: Avviso aggiornato
+ announcement_destroyed: Avviso eliminato
common:
free_plan: Free
yes: "Sì"
@@ -398,3 +402,64 @@ it:
club_recordings:
index:
title: "Replay — %{club}"
+ announcements:
+ index:
+ title: Avvisi
+ lead: Messaggi verso le app, il sito pubblico e l'area riservata — manutenzione, aggiornamenti o comunicazioni.
+ new: Nuovo avviso
+ table_title: Avvisi
+ none: Nessun avviso. Crea il primo per notificare app e sito.
+ new:
+ title: Nuovo avviso
+ edit:
+ title: Modifica avviso
+ table:
+ status: Stato
+ kind: Tipo
+ title: Messaggio
+ channels: Destinazioni
+ window: Finestra
+ actions:
+ edit: Modifica
+ delete: Elimina
+ delete_confirm: Eliminare questo avviso? Non sarà più visibile nelle destinazioni scelte.
+ kind:
+ info: Informazione
+ maintenance: Manutenzione
+ update: Aggiornamento app
+ severity:
+ info: Info
+ warning: Attenzione
+ critical: Critico
+ status:
+ draft: Bozza
+ published: Pubblicato
+ archived: Archiviato
+ channels:
+ app: App (Android e iOS)
+ web_public: Sito pubblico
+ web_private: Area riservata
+ window:
+ always: Sempre (finché pubblicato)
+ from: "Da %{time}"
+ until: "Fino a %{time}"
+ form:
+ kind: Tipo
+ severity: Gravità
+ status: Stato
+ channels: Dove mostrarlo
+ channels_hint: Puoi selezionare una o più destinazioni. L'area riservata è visibile dopo il login (società, squadre, account).
+ title: Titolo
+ body: Testo
+ starts_at: Inizio visibilità
+ ends_at: Fine visibilità
+ window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
+ action_url: Link (facoltativo)
+ action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store. Altrimenti un URL https.
+ action_label: Etichetta del pulsante
+ dismissible: L'utente può chiudere l'avviso
+ dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
+ submit_create: Salva avviso
+ submit_update: Aggiorna avviso
+ cancel: Annulla
+
diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml
index 25cb167..e56dc64 100644
--- a/backend/config/locales/app.de.yml
+++ b/backend/config/locales/app.de.yml
@@ -21,6 +21,8 @@ de:
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
too_long: "ist zu lang (höchstens %{count} Zeichen)"
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
+ app_announcement:
+ no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
club:
back_to_club: "← Verein"
sport_label: Hauptsportart
diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml
index 9bd8337..3a01a56 100644
--- a/backend/config/locales/app.en.yml
+++ b/backend/config/locales/app.en.yml
@@ -16,6 +16,8 @@ en:
too_short: "is too short (minimum is %{count} characters)"
too_long: "is too long (maximum is %{count} characters)"
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
+ app_announcement:
+ no_channel: Select at least one destination (apps, public website or logged-in area).
club:
back_to_club: "← Club"
sport_label: Main sport
@@ -39,6 +41,8 @@ en:
cover_file_label: Cover image
cover_preview_alt: Live cover preview
cover_remove_label: Remove custom cover
+ cover_reset_button: Restore standard cover
+ cover_reset_pending_hint: The standard cover will be restored when you save.
cover_requires_full: Custom sponsor covers are available on the Premium Full plan.
cover_see_plans: See plans
dashboard:
diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml
index f49c574..8953dcb 100644
--- a/backend/config/locales/app.es.yml
+++ b/backend/config/locales/app.es.yml
@@ -21,6 +21,8 @@ es:
too_short: "es demasiado corta (mínimo %{count} caracteres)"
too_long: "es demasiado larga (máximo %{count} caracteres)"
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
+ app_announcement:
+ no_channel: Elige al menos un destino (apps, sitio público o área privada).
club:
back_to_club: "← Club"
sport_label: Deporte principal
diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml
index ebedbd4..52c0cbb 100644
--- a/backend/config/locales/app.fr.yml
+++ b/backend/config/locales/app.fr.yml
@@ -21,6 +21,8 @@ fr:
too_short: "est trop court (minimum %{count} caractères)"
too_long: "est trop long (maximum %{count} caractères)"
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
+ app_announcement:
+ no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
club:
back_to_club: "← Club"
sport_label: Sport principal
diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml
index d8c5220..566028d 100644
--- a/backend/config/locales/app.it.yml
+++ b/backend/config/locales/app.it.yml
@@ -16,6 +16,8 @@ it:
too_short: "è troppo corta (minimo %{count} caratteri)"
too_long: "è troppo lunga (massimo %{count} caratteri)"
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
+ app_announcement:
+ no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
club:
back_to_club: "← Società"
sport_label: Sport principale
@@ -39,6 +41,8 @@ it:
cover_file_label: Immagine copertina
cover_preview_alt: Anteprima copertina diretta
cover_remove_label: Rimuovi copertina personalizzata
+ cover_reset_button: Ripristina copertina standard
+ cover_reset_pending_hint: Verrà ripristinata la copertina standard al salvataggio.
cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full.
cover_see_plans: Vedi piani
dashboard:
diff --git a/backend/config/locales/legal.de.yml b/backend/config/locales/legal.de.yml
index c521da1..4e59fbb 100644
--- a/backend/config/locales/legal.de.yml
+++ b/backend/config/locales/legal.de.yml
@@ -28,6 +28,7 @@ de:
s3_item4_html: "
Audiovisuelle Inhalte: Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
s3_item5_html: "
Zahlungsdaten: Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
s3_item6_html: "
Nutzungsdaten: Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
+ s3_item7_html: "
Daten des Kontaktformulars: Name, E-Mail, optionaler Verein, Anliegen und Nachricht von der Kontaktseite."
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare
minderjährige Athleten erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
diff --git a/backend/config/locales/legal.en.yml b/backend/config/locales/legal.en.yml
index dbef280..e8545e4 100644
--- a/backend/config/locales/legal.en.yml
+++ b/backend/config/locales/legal.en.yml
@@ -28,6 +28,7 @@ en:
s3_item4_html: "
Audiovisual content: video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
s3_item5_html: "
Payment data: for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
s3_item6_html: "
Browsing data: server logs, IP address, user agent, needed for the security and operation of the site."
+ s3_item7_html: "
Contact form data: name, email, optional club, topic and message submitted from the Contact page."
s4_title: 4. Processing of images and data of minor athletes
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable
minor athletes (image, voice, jersey number, game data)."
s4_lead1: Who is responsible for processing images of minors
@@ -56,6 +57,8 @@ en:
s5_row5_basis: Performance of a contract
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
s5_row6_basis: Consent or legitimate interest, depending on the case
+ s5_row7_purpose: Handling requests sent via the contact form
+ s5_row7_basis: Pre-contractual measures; legitimate interest in answering enquiries
s6_title: 6. Recipients and data processors
s6_intro: "Data may be shared with providers acting as data processors, including:"
s6_item1: hosting and cloud/server infrastructure provider;
diff --git a/backend/config/locales/legal.es.yml b/backend/config/locales/legal.es.yml
index 851ba1a..0998e70 100644
--- a/backend/config/locales/legal.es.yml
+++ b/backend/config/locales/legal.es.yml
@@ -28,6 +28,7 @@ es:
s3_item4_html: "
Contenidos audiovisuales: flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
s3_item5_html: "
Datos de pago: para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
s3_item6_html: "
Datos de navegación: registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
+ s3_item7_html: "
Datos del formulario de contacto: nombre, email, club opcional, motivo y mensaje enviados desde la página de Contacto."
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a
deportistas menores de edad identificables (imagen, voz, número de camiseta, datos de juego)."
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
diff --git a/backend/config/locales/legal.fr.yml b/backend/config/locales/legal.fr.yml
index fc9e061..2c5efdc 100644
--- a/backend/config/locales/legal.fr.yml
+++ b/backend/config/locales/legal.fr.yml
@@ -28,6 +28,7 @@ fr:
s3_item4_html: "
Contenus audiovisuels : flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
s3_item5_html: "
Données de paiement : pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
s3_item6_html: "
Données de navigation : journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
+ s3_item7_html: "
Données du formulaire de contact : nom, email, club éventuel, motif et message envoyés depuis la page Contact."
s4_title: 4. Traitement des images et données des athlètes mineurs
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des
athlètes mineurs identifiables (image, voix, numéro de maillot, données de jeu)."
s4_lead1: Qui est responsable du traitement des images des mineurs
diff --git a/backend/config/locales/legal.it.yml b/backend/config/locales/legal.it.yml
index 783bba5..4ba78e2 100644
--- a/backend/config/locales/legal.it.yml
+++ b/backend/config/locales/legal.it.yml
@@ -28,6 +28,7 @@ it:
s3_item4_html: "
Contenuti audiovisivi: flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato."
s3_item5_html: "
Dati di pagamento: per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta."
s3_item6_html: "
Dati di navigazione: log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
+ s3_item7_html: "
Dati del modulo contatti: nome, email, eventuale società, motivo e messaggio inviati dalla pagina Contatti."
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
s4_p1_html: "Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18). Le dirette possono riprendere
atleti minorenni identificabili (immagine, voce, numero di maglia, dati di gioco)."
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
@@ -56,6 +57,8 @@ it:
s5_row5_basis: Esecuzione del contratto
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
+ s5_row7_purpose: Gestione delle richieste inviate dal modulo contatti
+ s5_row7_basis: Misure precontrattuali; legittimo interesse a rispondere alle richieste
s6_title: 6. Destinatari e responsabili del trattamento
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
s6_item1: fornitore di hosting e infrastruttura cloud/server;
diff --git a/backend/config/locales/pages.de.yml b/backend/config/locales/pages.de.yml
index 2d06f2d..907ce39 100644
--- a/backend/config/locales/pages.de.yml
+++ b/backend/config/locales/pages.de.yml
@@ -175,3 +175,40 @@ de:
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
cta_start: Kostenlos starten
cta_faq: Häufige Fragen
+ contacts:
+ meta_title: "Kontakt — Match Live TV"
+ meta_description: "Kontakt zu Match Live TV: Angebote für Sportvereine, technischer Support und Datenschutzanfragen. Sitz in Mailand."
+ eyebrow: Match Live TV
+ title: Kontakt
+ lead: Schreib uns für ein Angebot, App-Support oder eine Datenschutzanfrage. Wir antworten in der Regel innerhalb eines Werktags.
+ response_time: "Typische Antwortzeit: 1 Werktag"
+ commercial_title: Vertrieb
+ commercial_body: Maßgeschneiderte Tarife, mehrere Teams, Turniere oder Integrationen. Schreib an
+ support_title: Support
+ support_body_html: "Probleme mit Zugang, Livestream oder App. Sieh zuerst die %{faq_link} oder die Seite %{support_link} an oder schreib an"
+ support_link: Support
+ faq_link: FAQ
+ privacy_title: Datenschutz
+ privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
+ company_title: Sitz
+ company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
+ label_address: Adresse
+ label_vat: USt-IdNr.
+ form_title: Nachricht senden
+ form_lead: Fülle das Formular aus: Wir antworten an die angegebene E-Mail-Adresse.
+ name_label: Name
+ email_label: E-Mail
+ club_label: Sportverein (optional)
+ topic_label: Anliegen
+ topic_commercial: Vertrieb / Angebot
+ topic_support: Technischer Support
+ topic_privacy: Datenschutz / DSGVO
+ message_label: Nachricht
+ honeypot_label: Website
+ privacy_accept_html: Ich stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu, wie in der %{privacy_link} beschrieben.
+ privacy_link: Datenschutzerklärung
+ submit: Nachricht senden
+ cta_title: Lieber selbst nachsehen?
+ cta_body: Viele Antworten stehen bereits in den FAQ. Um mit dem Streamen zu starten, registriere den Verein.
+ cta_faq: FAQ
+ cta_signup: Team registrieren
diff --git a/backend/config/locales/pages.en.yml b/backend/config/locales/pages.en.yml
index a843491..8a9a495 100644
--- a/backend/config/locales/pages.en.yml
+++ b/backend/config/locales/pages.en.yml
@@ -175,3 +175,40 @@ en:
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
cta_start: Start for free
cta_faq: Frequently asked questions
+ contacts:
+ meta_title: "Contact — Match Live TV"
+ meta_description: "Contact Match Live TV: quotes for sports clubs, technical support and privacy requests. Based in Milan."
+ eyebrow: Match Live TV
+ title: Contact
+ lead: Write to us for a quote, app support or a privacy request. We usually reply within one working day.
+ response_time: "Typical reply time: 1 working day"
+ commercial_title: Sales
+ commercial_body: Custom plans, multiple teams, tournaments or integrations. Write to
+ support_title: Support
+ support_body_html: "Sign-in, live stream or app issues. Check the %{faq_link} or the %{support_link} page first, or write to"
+ support_link: Support
+ faq_link: FAQ
+ privacy_title: Privacy
+ privacy_body: GDPR rights, consent and personal data. Write to
+ company_title: Company details
+ company_lead: Service provider and data controller.
+ label_address: Address
+ label_vat: VAT number
+ form_title: Send a message
+ form_lead: Fill in the form and we will reply to the email you provide.
+ name_label: Name
+ email_label: Email
+ club_label: Sports club (optional)
+ topic_label: Topic
+ topic_commercial: Sales / quote
+ topic_support: Technical support
+ topic_privacy: Privacy / GDPR
+ message_label: Message
+ honeypot_label: Website
+ privacy_accept_html: I agree to the processing of my data to handle this request, as described in the %{privacy_link}.
+ privacy_link: privacy notice
+ submit: Send message
+ cta_title: Prefer to look it up yourself?
+ cta_body: Many answers are already in the FAQ. To start streaming, register your club.
+ cta_faq: FAQ
+ cta_signup: Register your team
diff --git a/backend/config/locales/pages.es.yml b/backend/config/locales/pages.es.yml
index 75b3a82..b28e5ac 100644
--- a/backend/config/locales/pages.es.yml
+++ b/backend/config/locales/pages.es.yml
@@ -175,3 +175,40 @@ es:
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
cta_start: Empieza gratis
cta_faq: Preguntas frecuentes
+ contacts:
+ meta_title: "Contacto — Match Live TV"
+ meta_description: "Contacta con Match Live TV: presupuestos para clubes deportivos, soporte técnico y solicitudes de privacidad. Sede en Milán."
+ eyebrow: Match Live TV
+ title: Contacto
+ lead: Escríbenos para un presupuesto, para ayuda con la app o para una solicitud de privacidad. Solemos responder en un día laborable.
+ response_time: "Tiempo de respuesta orientativo: 1 día laborable"
+ commercial_title: Comercial
+ commercial_body: Planes a medida, varios equipos, torneos o integraciones. Escribe a
+ support_title: Asistencia
+ support_body_html: "Problemas de acceso, directos o app. Consulta primero las %{faq_link} o la página %{support_link}, o escribe a"
+ support_link: Soporte
+ faq_link: FAQ
+ privacy_title: Privacidad
+ privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
+ company_title: Sede
+ company_lead: Prestador del servicio y responsable del tratamiento.
+ label_address: Dirección
+ label_vat: NIF / IVA
+ form_title: Enviar un mensaje
+ form_lead: Completa el formulario: te responderemos al email que indiques.
+ name_label: Nombre
+ email_label: Email
+ club_label: Club deportivo (opcional)
+ topic_label: Motivo
+ topic_commercial: Comercial / presupuesto
+ topic_support: Asistencia técnica
+ topic_privacy: Privacidad / RGPD
+ message_label: Mensaje
+ honeypot_label: Sitio web
+ privacy_accept_html: Acepto el tratamiento de los datos para gestionar esta solicitud, como se describe en la %{privacy_link}.
+ privacy_link: política de privacidad
+ submit: Enviar mensaje
+ cta_title: "¿Prefieres buscarlo tú?"
+ cta_body: Muchas respuestas ya están en las FAQ. Para empezar a emitir, registra el club.
+ cta_faq: FAQ
+ cta_signup: Registrar el equipo
diff --git a/backend/config/locales/pages.fr.yml b/backend/config/locales/pages.fr.yml
index 4c807d0..fc6f342 100644
--- a/backend/config/locales/pages.fr.yml
+++ b/backend/config/locales/pages.fr.yml
@@ -175,3 +175,40 @@ fr:
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
cta_start: Commencer gratuitement
cta_faq: Questions fréquentes
+ contacts:
+ meta_title: "Contact — Match Live TV"
+ meta_description: "Contactez Match Live TV : devis pour clubs sportifs, assistance technique et demandes privacy. Siège à Milan."
+ eyebrow: Match Live TV
+ title: Contact
+ lead: Écrivez-nous pour un devis, pour l'assistance de l'app ou pour une demande privacy. Nous répondons en général sous un jour ouvrable.
+ response_time: "Délai indicatif : 1 jour ouvrable"
+ commercial_title: Commercial
+ commercial_body: Offres sur mesure, plusieurs équipes, tournois ou intégrations. Écrivez à
+ support_title: Assistance
+ support_body_html: "Problèmes d'accès, de direct ou d'application. Consultez d'abord les %{faq_link} ou la page %{support_link}, ou écrivez à"
+ support_link: Support
+ faq_link: FAQ
+ privacy_title: Confidentialité
+ privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
+ company_title: Siège
+ company_lead: Prestataire du service et responsable du traitement.
+ label_address: Adresse
+ label_vat: N° de TVA
+ form_title: Envoyer un message
+ form_lead: Remplissez le formulaire : nous vous répondons à l'email indiqué.
+ name_label: Nom
+ email_label: Email
+ club_label: Club sportif (facultatif)
+ topic_label: Motif
+ topic_commercial: Commercial / devis
+ topic_support: Assistance technique
+ topic_privacy: Confidentialité / RGPD
+ message_label: Message
+ honeypot_label: Site web
+ privacy_accept_html: J'accepte le traitement des données pour gérer cette demande, comme décrit dans la %{privacy_link}.
+ privacy_link: notice de confidentialité
+ submit: Envoyer le message
+ cta_title: Vous préférez chercher vous-même ?
+ cta_body: Beaucoup de réponses sont déjà dans la FAQ. Pour commencer à diffuser, inscrivez le club.
+ cta_faq: FAQ
+ cta_signup: Enregistrer l'équipe
diff --git a/backend/config/locales/pages.it.yml b/backend/config/locales/pages.it.yml
index f2aede9..0ceae67 100644
--- a/backend/config/locales/pages.it.yml
+++ b/backend/config/locales/pages.it.yml
@@ -175,3 +175,40 @@ it:
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
cta_start: Inizia gratis
cta_faq: Domande frequenti
+ contacts:
+ meta_title: "Contatti — Match Live TV"
+ meta_description: "Contatta Match Live TV: preventivi per società sportive, assistenza tecnica e richieste privacy. Sede a Milano."
+ eyebrow: Match Live TV
+ title: Contatti
+ lead: Scrivici per un preventivo, per l'assistenza sull'app o per una richiesta privacy. Rispondiamo in genere entro un giorno lavorativo.
+ response_time: "Risposta indicativa: 1 giorno lavorativo"
+ commercial_title: Commerciale
+ commercial_body: Piani su misura, più squadre, tornei o integrazioni. Scrivi a
+ support_title: Assistenza
+ support_body_html: "Problemi di accesso, dirette o app. Consulta prima le %{faq_link} o la pagina %{support_link}, oppure scrivi a"
+ support_link: Supporto
+ faq_link: FAQ
+ privacy_title: Privacy
+ privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
+ company_title: Sede
+ company_lead: Titolare del servizio e del trattamento dei dati.
+ label_address: Indirizzo
+ label_vat: Partita IVA
+ form_title: Invia un messaggio
+ form_lead: Compila il modulo: ti rispondiamo all'email che indichi.
+ name_label: Nome
+ email_label: Email
+ club_label: Società sportiva (opzionale)
+ topic_label: Motivo
+ topic_commercial: Commerciale / preventivo
+ topic_support: Assistenza tecnica
+ topic_privacy: Privacy / GDPR
+ message_label: Messaggio
+ honeypot_label: Sito web
+ privacy_accept_html: Acconsento al trattamento dei dati per gestire questa richiesta, come descritto nella %{privacy_link}.
+ privacy_link: informativa privacy
+ submit: Invia messaggio
+ cta_title: Preferisci fare da solo?
+ cta_body: Molte risposte sono già nelle FAQ. Per iniziare a trasmettere, registra la società.
+ cta_faq: FAQ
+ cta_signup: Registra la squadra
diff --git a/backend/config/locales/ui.de.yml b/backend/config/locales/ui.de.yml
index 5dfb60e..5ee4637 100644
--- a/backend/config/locales/ui.de.yml
+++ b/backend/config/locales/ui.de.yml
@@ -141,6 +141,7 @@ de:
common:
privacy: Datenschutz
support: Support
+ contacts: Kontakt
cookies: Cookies
terms: AGB
pricing: Preise
@@ -174,3 +175,7 @@ de:
choice_youtube_hint_only: Entfernt nur das YouTube-Video
choice_both_label: Website und YouTube
choice_both_hint: Vollständige Löschung
+ announcement:
+ region: Hinweise
+ dismiss: Hinweis schließen
+ open: Öffnen
diff --git a/backend/config/locales/ui.en.yml b/backend/config/locales/ui.en.yml
index 1a91199..f30d8e6 100644
--- a/backend/config/locales/ui.en.yml
+++ b/backend/config/locales/ui.en.yml
@@ -141,6 +141,7 @@ en:
common:
privacy: Privacy
support: Support
+ contacts: Contacts
cookies: Cookies
terms: Terms
pricing: Pricing
@@ -174,3 +175,7 @@ en:
choice_youtube_hint_only: Removes only the YouTube video
choice_both_label: Site and YouTube
choice_both_hint: Complete deletion
+ announcement:
+ region: Notices
+ dismiss: Dismiss notice
+ open: Open
diff --git a/backend/config/locales/ui.es.yml b/backend/config/locales/ui.es.yml
index 280649a..a8dd1a8 100644
--- a/backend/config/locales/ui.es.yml
+++ b/backend/config/locales/ui.es.yml
@@ -141,6 +141,7 @@ es:
common:
privacy: Privacidad
support: Soporte
+ contacts: Contacto
cookies: Cookies
terms: Términos
pricing: Precios
@@ -174,3 +175,7 @@ es:
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
choice_both_label: Sitio y YouTube
choice_both_hint: Eliminación completa
+ announcement:
+ region: Avisos
+ dismiss: Cerrar aviso
+ open: Abrir
diff --git a/backend/config/locales/ui.fr.yml b/backend/config/locales/ui.fr.yml
index 85c7a34..6ef871b 100644
--- a/backend/config/locales/ui.fr.yml
+++ b/backend/config/locales/ui.fr.yml
@@ -141,6 +141,7 @@ fr:
common:
privacy: Confidentialité
support: Support
+ contacts: Contacts
cookies: Cookies
terms: Conditions
pricing: Tarifs
@@ -174,3 +175,7 @@ fr:
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
choice_both_label: Site et YouTube
choice_both_hint: Suppression complète
+ announcement:
+ region: Alertes
+ dismiss: Fermer l'alerte
+ open: Ouvrir
diff --git a/backend/config/locales/ui.it.yml b/backend/config/locales/ui.it.yml
index f11b379..dee3f05 100644
--- a/backend/config/locales/ui.it.yml
+++ b/backend/config/locales/ui.it.yml
@@ -141,6 +141,7 @@ it:
common:
privacy: Privacy
support: Supporto
+ contacts: Contatti
cookies: Cookie
terms: Termini
pricing: Prezzi
@@ -174,3 +175,7 @@ it:
choice_youtube_hint_only: Rimuove solo il video YouTube
choice_both_label: Da sito e YouTube
choice_both_hint: Eliminazione completa
+ announcement:
+ region: Avvisi
+ dismiss: Chiudi avviso
+ open: Apri
diff --git a/backend/config/routes.rb b/backend/config/routes.rb
index db539bb..ba5e3f7 100644
--- a/backend/config/routes.rb
+++ b/backend/config/routes.rb
@@ -16,6 +16,7 @@ Rails.application.routes.draw do
patch "account", to: "accounts#update"
patch "account/password", to: "accounts#password"
get "sports", to: "sports#index"
+ get "announcements", to: "announcements#index"
get "invitations/:token", to: "invitations#show"
post "invitations/:token/accept", to: "invitations#accept"
@@ -84,6 +85,7 @@ Rails.application.routes.draw do
root to: "dashboard#index"
get "metrics", to: "dashboard#metrics"
+ resources :announcements, except: %i[show]
resources :ops, only: [:index], controller: "ops" do
member do
post :acknowledge
@@ -170,6 +172,9 @@ Rails.application.routes.draw do
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
get "prezzi", to: "pages#pricing", as: :prezzi
get "pricing", to: redirect("/prezzi")
+ get "contatti", to: "contacts#show", as: :contatti
+ post "contatti", to: "contacts#create"
+ get "contact", to: redirect("/contatti")
get "privacy", to: "pages#privacy", as: :privacy
get "support", to: "pages#support", as: :support
get "cookie", to: "pages#cookies", as: :cookies
diff --git a/backend/db/migrate/20260820140000_create_app_announcements.rb b/backend/db/migrate/20260820140000_create_app_announcements.rb
new file mode 100644
index 0000000..6ade263
--- /dev/null
+++ b/backend/db/migrate/20260820140000_create_app_announcements.rb
@@ -0,0 +1,25 @@
+class CreateAppAnnouncements < ActiveRecord::Migration[7.2]
+ def change
+ create_table :app_announcements, id: :uuid do |t|
+ t.string :kind, null: false, default: "info"
+ t.string :severity, null: false, default: "info"
+ t.string :status, null: false, default: "draft"
+ t.boolean :show_on_app, null: false, default: true
+ t.boolean :show_on_web_public, null: false, default: false
+ t.boolean :show_on_web_private, null: false, default: false
+ t.string :title, null: false
+ t.text :body, null: false
+ t.string :action_url
+ t.string :action_label
+ t.boolean :dismissible, null: false, default: true
+ t.datetime :starts_at
+ t.datetime :ends_at
+ t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
+
+ t.timestamps
+ end
+
+ add_index :app_announcements, :status
+ add_index :app_announcements, %i[status starts_at ends_at]
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index 13c6f08..7444af5 100644
--- a/backend/db/schema.rb
+++ b/backend/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
+ActiveRecord::Schema[7.2].define(version: 2026_08_20_140000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -51,6 +51,28 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
end
+ create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
+ t.string "kind", default: "info", null: false
+ t.string "severity", default: "info", null: false
+ t.string "status", default: "draft", null: false
+ t.boolean "show_on_app", default: true, null: false
+ t.boolean "show_on_web_public", default: false, null: false
+ t.boolean "show_on_web_private", default: false, null: false
+ t.string "title", null: false
+ t.text "body", null: false
+ t.string "action_url"
+ t.string "action_label"
+ t.boolean "dismissible", default: true, null: false
+ t.datetime "starts_at"
+ t.datetime "ends_at"
+ t.uuid "created_by_admin_id"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["created_by_admin_id"], name: "index_app_announcements_on_created_by_admin_id"
+ t.index ["status", "starts_at", "ends_at"], name: "index_app_announcements_on_status_and_starts_at_and_ends_at"
+ t.index ["status"], name: "index_app_announcements_on_status"
+ end
+
create_table "billing_club_quotes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.uuid "club_id", null: false
t.string "plan_slug", null: false
@@ -471,6 +493,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
+ add_foreign_key "app_announcements", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "admin_accounts", column: "created_by_admin_id"
add_foreign_key "billing_club_quotes", "clubs"
add_foreign_key "billing_invoices", "billing_payments"
diff --git a/backend/public/admin.css b/backend/public/admin.css
index eb90ed5..4b22020 100644
--- a/backend/public/admin.css
+++ b/backend/public/admin.css
@@ -582,3 +582,86 @@ body.admin-body {
color: var(--red) !important;
font-weight: 600;
}
+
+.admin-form {
+ display: grid;
+ gap: 0.9rem;
+ max-width: 720px;
+}
+
+.admin-form-row {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.admin-form label {
+ display: block;
+ font-size: 0.8rem;
+ color: var(--muted);
+ margin-bottom: 0.3rem;
+}
+
+.admin-form input:not([type="checkbox"]),
+.admin-form select,
+.admin-form textarea {
+ width: 100%;
+ padding: 0.5rem 0.65rem;
+ border: 1px solid #444;
+ border-radius: 6px;
+ background: #1a1a22;
+ color: #eee;
+ font: inherit;
+}
+
+.admin-form textarea {
+ min-height: 7rem;
+ resize: vertical;
+}
+
+.admin-form-hint {
+ margin: -0.4rem 0 0;
+ font-size: 0.8rem;
+}
+
+.admin-checkbox {
+ display: flex;
+ align-items: center;
+ gap: 0.55rem;
+ color: var(--text);
+ font-size: 0.9rem;
+}
+
+.admin-checkbox input[type="checkbox"] {
+ width: 1rem;
+ height: 1rem;
+}
+
+.admin-channels {
+ margin: 0;
+ padding: 0.85rem 1rem;
+ border: 1px solid #444;
+ border-radius: 8px;
+ display: grid;
+ gap: 0.55rem;
+}
+
+.admin-channels legend {
+ padding: 0 0.35rem;
+ font-size: 0.8rem;
+ color: var(--muted);
+}
+
+.admin-form-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.75rem;
+ margin-top: 0.35rem;
+}
+
+@media (max-width: 720px) {
+ .admin-form-row {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/backend/public/marketing.css b/backend/public/marketing.css
index e1c42be..aae7bbc 100644
--- a/backend/public/marketing.css
+++ b/backend/public/marketing.css
@@ -9,6 +9,9 @@ body {
a { color: #ff6b6b; text-decoration: none; }
a:hover { text-decoration: underline; }
.wrap { max-width: 1100px; margin: 0 auto; padding: 0 20px 48px; }
+/* Form portale (società/squadra + copertina 16:9): usa tutta la larghezza desktop, non stile “mobile”. */
+.wrap--portal { max-width: 1100px; }
+.wrap--portal .card { max-width: none; }
.site-chrome {
position: sticky;
top: 0;
@@ -450,15 +453,19 @@ body.nav-menu-open { overflow: hidden; }
object-fit: contain;
}
.branding-cover-preview {
- min-height: 140px;
+ min-height: 0;
+ aspect-ratio: 16 / 9;
+ width: 100%;
+ padding: 0;
+ overflow: hidden;
}
.branding-cover-preview__img {
- max-width: 100%;
- max-height: 180px;
- width: auto;
- height: auto;
+ max-width: none;
+ max-height: none;
+ width: 100%;
+ height: 100%;
border-radius: 8px;
- object-fit: contain;
+ object-fit: cover;
}
.cover-remove-label {
display: flex;
@@ -468,6 +475,27 @@ body.nav-menu-open { overflow: hidden; }
font-size: 0.9rem;
color: #aaa;
}
+.cover-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 12px;
+ margin-top: 12px;
+}
+.cover-reset-btn {
+ padding: 10px 16px;
+ font-size: 0.9rem;
+ white-space: nowrap;
+}
+.cover-reset-btn:disabled {
+ opacity: 0.55;
+ cursor: default;
+}
+.cover-reset-hint {
+ flex: 1 1 100%;
+ margin: 0;
+ color: #a5f0b8;
+}
.branding-file-input { margin-bottom: 0 !important; }
.branding-url-input { margin-bottom: 14px; }
.branding-color-row {
@@ -3257,3 +3285,99 @@ a.replay-archive__thumb:hover {
margin-top: 8px;
}
}
+
+.site-announcements {
+ max-width: 1100px;
+ margin: 0 auto;
+ padding: 12px 20px 0;
+ display: grid;
+ gap: 10px;
+}
+
+.live-main .site-announcements {
+ max-width: none;
+ padding-left: 0;
+ padding-right: 0;
+}
+
+.site-announcement {
+ display: flex;
+ align-items: flex-start;
+ gap: 12px;
+ border-radius: 12px;
+ padding: 14px 16px;
+ border: 1px solid #3a3a48;
+ background: #16161e;
+}
+
+.site-announcement__body {
+ flex: 1;
+ min-width: 0;
+}
+
+.site-announcement__title {
+ margin: 0 0 4px;
+ font-weight: 700;
+}
+
+.site-announcement__text {
+ margin: 0;
+ color: #c8c8d4;
+ font-size: 0.95rem;
+}
+
+.site-announcement__text p {
+ margin: 0 0 0.4em;
+}
+
+.site-announcement__text p:last-child {
+ margin-bottom: 0;
+}
+
+.site-announcement__action {
+ margin: 8px 0 0;
+}
+
+.site-announcement__link {
+ font-weight: 700;
+}
+
+.site-announcement__dismiss {
+ flex-shrink: 0;
+ width: 32px;
+ height: 32px;
+ border: 0;
+ border-radius: 8px;
+ background: transparent;
+ color: #9a9aad;
+ font-size: 1.4rem;
+ line-height: 1;
+ cursor: pointer;
+}
+
+.site-announcement__dismiss:hover {
+ color: #fff;
+ background: #2a2a36;
+}
+
+.site-announcement--info {
+ border-color: #e53935;
+ background: rgba(229, 57, 53, 0.12);
+}
+
+.site-announcement--maintenance,
+.site-announcement--warning {
+ border-color: #f5c518;
+ background: rgba(245, 197, 24, 0.12);
+}
+
+.site-announcement--update,
+.site-announcement--success {
+ border-color: #22c55e;
+ background: rgba(34, 197, 94, 0.12);
+}
+
+.site-announcement--critical {
+ border-color: #e53935;
+ background: rgba(229, 57, 53, 0.16);
+}
diff --git a/backend/spec/models/app_announcement_spec.rb b/backend/spec/models/app_announcement_spec.rb
new file mode 100644
index 0000000..0ba0a38
--- /dev/null
+++ b/backend/spec/models/app_announcement_spec.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe AppAnnouncement do
+ def create_notice!(**attrs)
+ described_class.create!({
+ kind: "info",
+ severity: "info",
+ status: "published",
+ title: "Avviso",
+ body: "Testo",
+ show_on_app: true,
+ show_on_web_public: false,
+ show_on_web_private: false
+ }.merge(attrs))
+ end
+
+ it "is active on the app when published without a time window" do
+ item = create_notice!(kind: "maintenance", severity: "warning", title: "Manutenzione", body: "Domani dalle 2 alle 4.")
+
+ expect(described_class.for_app).to include(item)
+ expect(described_class.for_channel(:web_public)).not_to include(item)
+ end
+
+ it "hides drafts and expired notices" do
+ create_notice!(status: "draft", title: "Bozza", body: "Non visibile")
+ create_notice!(title: "Scaduto", body: "Fine", ends_at: 1.hour.ago)
+ live = create_notice!(kind: "update", title: "Aggiornamento", body: "Nuova versione")
+
+ expect(described_class.for_app.map(&:title)).to eq(["Aggiornamento"])
+ expect(live.as_api_json(platform: "android")[:action_url]).to eq(MatchLiveTv.play_store_url)
+ expect(live.as_api_json(platform: "ios")[:action_url]).to eq(MatchLiveTv.app_store_url)
+ end
+
+ it "filters by selected channels" do
+ public_item = create_notice!(
+ title: "Pubblico", show_on_app: false, show_on_web_public: true
+ )
+ private_item = create_notice!(
+ title: "Privato", show_on_app: false, show_on_web_private: true
+ )
+ app_item = create_notice!(title: "App")
+
+ expect(described_class.for_channel(:web_public)).to contain_exactly(public_item)
+ expect(described_class.for_channel(:web_private)).to contain_exactly(private_item)
+ expect(described_class.for_app).to contain_exactly(app_item)
+ end
+
+ it "requires at least one channel" do
+ item = described_class.new(
+ kind: "info", status: "draft", title: "X", body: "Y",
+ show_on_app: false, show_on_web_public: false, show_on_web_private: false
+ )
+ expect(item).not_to be_valid
+ expect(item.errors[:base]).to be_present
+ end
+end
diff --git a/backend/spec/requests/admin/announcements_spec.rb b/backend/spec/requests/admin/announcements_spec.rb
new file mode 100644
index 0000000..af4e724
--- /dev/null
+++ b/backend/spec/requests/admin/announcements_spec.rb
@@ -0,0 +1,50 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Admin announcements", type: :request do
+ let!(:admin) { AdminAccount.create!(username: "ops-news", password: "Password123") }
+
+ before do
+ post admin_login_path, params: { username: "ops-news", password: "Password123" }
+ end
+
+ it "crea e pubblica un avviso visibile alle app e al sito" do
+ post admin_announcements_path, params: {
+ app_announcement: {
+ kind: "update",
+ severity: "info",
+ status: "published",
+ title: "Nuova versione",
+ body: "Aggiorna l'app dallo store.",
+ dismissible: "1",
+ show_on_app: "1",
+ show_on_web_public: "1",
+ show_on_web_private: "0"
+ }
+ }
+
+ expect(response).to redirect_to(admin_announcements_path)
+ notice = AppAnnouncement.last
+ expect(notice.title).to eq("Nuova versione")
+ expect(notice).to be_published
+ expect(notice.show_on_app).to be(true)
+ expect(notice.show_on_web_public).to be(true)
+ expect(notice.show_on_web_private).to be(false)
+
+ get admin_announcements_path
+ expect(response.body).to include("Nuova versione")
+ expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
+ end
+
+ it "elimina un avviso" do
+ item = AppAnnouncement.create!(
+ kind: "info", status: "published", title: "Da togliere", body: "Test"
+ )
+
+ delete admin_announcement_path(item)
+
+ expect(response).to redirect_to(admin_announcements_path)
+ expect(AppAnnouncement.find_by(id: item.id)).to be_nil
+ end
+end
diff --git a/backend/spec/requests/api/v1/announcements_spec.rb b/backend/spec/requests/api/v1/announcements_spec.rb
new file mode 100644
index 0000000..a6cfcf1
--- /dev/null
+++ b/backend/spec/requests/api/v1/announcements_spec.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Api::V1::Announcements", type: :request do
+ it "GET /api/v1/announcements senza autenticazione restituisce solo gli avvisi attivi per l'app" do
+ AppAnnouncement.create!(
+ kind: "maintenance",
+ severity: "warning",
+ status: "published",
+ title: "Manutenzione stasera",
+ body: "Dalle 23:00 alle 01:00 il servizio sarà offline.",
+ show_on_app: true
+ )
+ AppAnnouncement.create!(
+ kind: "info",
+ status: "published",
+ title: "Solo sito",
+ body: "Non in app",
+ show_on_app: false,
+ show_on_web_public: true
+ )
+ AppAnnouncement.create!(
+ kind: "info",
+ status: "draft",
+ title: "Bozza",
+ body: "Non deve uscire"
+ )
+
+ get "/api/v1/announcements", params: { platform: "android" }
+
+ expect(response).to have_http_status(:ok)
+ body = JSON.parse(response.body)
+ expect(body.length).to eq(1)
+ expect(body.first["title"]).to eq("Manutenzione stasera")
+ expect(body.first["kind"]).to eq("maintenance")
+ expect(body.first["dismissible"]).to eq(true)
+ end
+end
diff --git a/backend/spec/requests/public/announcements_spec.rb b/backend/spec/requests/public/announcements_spec.rb
new file mode 100644
index 0000000..afdf5ba
--- /dev/null
+++ b/backend/spec/requests/public/announcements_spec.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require "rails_helper"
+
+RSpec.describe "Public site announcements", type: :request do
+ let!(:coach) do
+ User.create!(email: "news-coach@test.it", name: "Coach", password: "Password123", role: "coach")
+ end
+
+ it "mostra gli avvisi pubblici in home e non quelli privati" do
+ AppAnnouncement.create!(
+ kind: "maintenance", status: "published", title: "Manutenzione sito",
+ body: "Stasera il sito sarà offline.",
+ show_on_app: false, show_on_web_public: true, show_on_web_private: false
+ )
+ AppAnnouncement.create!(
+ kind: "info", status: "published", title: "Solo soci",
+ body: "Messaggio interno.",
+ show_on_app: false, show_on_web_public: false, show_on_web_private: true
+ )
+
+ get root_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Manutenzione sito")
+ expect(response.body).not_to include("Solo soci")
+ end
+
+ it "mostra gli avvisi privati nell'area account dopo il login" do
+ AppAnnouncement.create!(
+ kind: "info", status: "published", title: "Solo soci",
+ body: "Messaggio interno.",
+ show_on_app: false, show_on_web_public: false, show_on_web_private: true
+ )
+ AppAnnouncement.create!(
+ kind: "info", status: "published", title: "Manutenzione sito",
+ body: "Stasera il sito sarà offline.",
+ show_on_app: false, show_on_web_public: true, show_on_web_private: false
+ )
+
+ post public_login_path, params: { email: coach.email, password: "Password123" }
+ get public_account_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Solo soci")
+ expect(response.body).not_to include("Manutenzione sito")
+ end
+end
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AnnouncementStore.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AnnouncementStore.kt
new file mode 100644
index 0000000..4e7c0bd
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/core/AnnouncementStore.kt
@@ -0,0 +1,18 @@
+package com.matchlivetv.match_live_tv.core
+
+import android.content.Context
+
+class AnnouncementStore(context: Context) {
+ private val prefs = context.applicationContext.getSharedPreferences("announcements", Context.MODE_PRIVATE)
+
+ fun dismissedIds(): Set
=
+ prefs.getStringSet(KEY_DISMISSED, emptySet())?.toSet().orEmpty()
+
+ fun dismiss(id: String) {
+ prefs.edit().putStringSet(KEY_DISMISSED, dismissedIds() + id).apply()
+ }
+
+ companion object {
+ private const val KEY_DISMISSED = "dismissed_ids"
+ }
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
index f15b262..3b17120 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt
@@ -1,9 +1,11 @@
package com.matchlivetv.match_live_tv.data
import android.content.Context
+import com.matchlivetv.match_live_tv.core.AnnouncementStore
import com.matchlivetv.match_live_tv.core.AppConfig
import com.matchlivetv.match_live_tv.core.AppLocale
import com.matchlivetv.match_live_tv.core.TokenStore
+import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
import com.matchlivetv.match_live_tv.data.repository.AuthRepository
@@ -86,6 +88,14 @@ class AppContainer(context: Context) {
val matchRepository = MatchRepository(api, tokenStore, appContext)
+ val announcementStore = AnnouncementStore(appContext)
+
+ suspend fun visibleAnnouncements(): List {
+ val dismissed = announcementStore.dismissedIds()
+ return runCatching { api.announcements() }.getOrElse { emptyList() }
+ .filter { it.id !in dismissed }
+ }
+
val sessionRepository = SessionRepository(api)
val scoreRepository = ScoreRepository(api)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
index e9b8fbe..f9f7b28 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/ApiDtos.kt
@@ -414,3 +414,14 @@ data class TelemetryRequest(
val fps: Int? = null,
@Json(name = "thermal_state") val thermalState: String? = null,
)
+
+data class AnnouncementDto(
+ val id: String,
+ val kind: String,
+ val severity: String = "info",
+ val title: String,
+ val body: String,
+ val dismissible: Boolean = true,
+ @Json(name = "action_url") val actionUrl: String? = null,
+ @Json(name = "action_label") val actionLabel: String? = null,
+)
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
index 9f9b032..4efc217 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/api/MatchLiveApi.kt
@@ -8,6 +8,7 @@ import retrofit2.http.PATCH
import retrofit2.http.POST
import retrofit2.http.Part
import retrofit2.http.Path
+import retrofit2.http.Query
import okhttp3.MultipartBody
interface MatchLiveApi {
@@ -38,6 +39,9 @@ interface MatchLiveApi {
@GET("sports")
suspend fun sports(): List
+ @GET("announcements")
+ suspend fun announcements(@Query("platform") platform: String = "android"): List
+
@GET("teams")
suspend fun teams(): List
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/AnnouncementBanner.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/AnnouncementBanner.kt
new file mode 100644
index 0000000..c2ea6e5
--- /dev/null
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/components/AnnouncementBanner.kt
@@ -0,0 +1,102 @@
+package com.matchlivetv.match_live_tv.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Campaign
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Construction
+import androidx.compose.material.icons.filled.SystemUpdate
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalUriHandler
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import com.matchlivetv.match_live_tv.R
+import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
+import com.matchlivetv.match_live_tv.ui.theme.MatchColors
+
+@Composable
+fun AnnouncementBanners(
+ announcements: List,
+ onDismiss: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ if (announcements.isEmpty()) return
+ Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ announcements.forEach { item ->
+ AnnouncementBanner(item = item, onDismiss = { onDismiss(item.id) })
+ }
+ }
+}
+
+@Composable
+private fun AnnouncementBanner(
+ item: AnnouncementDto,
+ onDismiss: () -> Unit,
+) {
+ val uriHandler = LocalUriHandler.current
+ val accent = announcementAccent(item)
+ Column(
+ Modifier
+ .fillMaxWidth()
+ .background(accent.copy(alpha = 0.16f), RoundedCornerShape(12.dp))
+ .padding(14.dp),
+ ) {
+ Row(verticalAlignment = Alignment.Top) {
+ Icon(announcementIcon(item.kind), contentDescription = null, tint = accent)
+ Spacer(Modifier.width(12.dp))
+ Column(Modifier.weight(1f)) {
+ Text(item.title, color = accent, style = MaterialTheme.typography.titleMedium)
+ Spacer(Modifier.height(4.dp))
+ Text(item.body, style = MaterialTheme.typography.bodyMedium)
+ }
+ if (item.dismissible) {
+ IconButton(onClick = onDismiss) {
+ Icon(
+ Icons.Filled.Close,
+ contentDescription = stringResource(R.string.announcement_dismiss),
+ tint = MatchColors.TextSecondary,
+ )
+ }
+ }
+ }
+ val actionUrl = item.actionUrl?.takeIf { it.isNotBlank() }
+ if (actionUrl != null) {
+ TextButton(onClick = { runCatching { uriHandler.openUri(actionUrl) } }) {
+ Text(
+ item.actionLabel?.takeIf { it.isNotBlank() }
+ ?: stringResource(R.string.announcement_open),
+ color = accent,
+ )
+ }
+ }
+ }
+}
+
+private fun announcementAccent(item: AnnouncementDto): Color = when {
+ item.severity == "critical" || item.kind == "maintenance" -> MatchColors.AccentYellow
+ item.kind == "update" -> MatchColors.SuccessGreen
+ else -> MatchColors.PrimaryRed
+}
+
+private fun announcementIcon(kind: String) = when (kind) {
+ "maintenance" -> Icons.Filled.Construction
+ "update" -> Icons.Filled.SystemUpdate
+ else -> Icons.Filled.Campaign
+}
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
index 92102e7..32ec3d7 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/login/LoginScreen.kt
@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -52,6 +53,8 @@ import androidx.compose.ui.unit.dp
import com.matchlivetv.match_live_tv.BuildConfig
import com.matchlivetv.match_live_tv.R
import com.matchlivetv.match_live_tv.data.AppContainer
+import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
+import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -72,6 +75,7 @@ fun LoginScreen(
var loading by remember { mutableStateOf(false) }
var passwordVisible by remember { mutableStateOf(false) }
var showLanguagePicker by remember { mutableStateOf(false) }
+ var announcements by remember { mutableStateOf>(emptyList()) }
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
val errCredentials = stringResource(R.string.login_error_credentials)
@@ -98,6 +102,10 @@ fun LoginScreen(
}
}
+ LaunchedEffect(Unit) {
+ announcements = container.visibleAnnouncements()
+ }
+
MatchScreenScaffold(
topBar = {
TopAppBar(
@@ -142,6 +150,16 @@ fun LoginScreen(
Spacer(Modifier.height(16.dp))
}
MatchLiveWordmark(showSlogan = true)
+ if (announcements.isNotEmpty()) {
+ Spacer(Modifier.height(24.dp))
+ AnnouncementBanners(
+ announcements = announcements,
+ onDismiss = { id ->
+ container.announcementStore.dismiss(id)
+ announcements = announcements.filterNot { it.id == id }
+ },
+ )
+ }
Spacer(Modifier.height(48.dp))
Text(
text = stringResource(R.string.login_submit).uppercase(),
diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt
index ad845ef..13ff2e9 100644
--- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt
+++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/ui/matches/MatchesScreen.kt
@@ -53,6 +53,8 @@ import com.matchlivetv.match_live_tv.data.AppContainer
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
import com.matchlivetv.match_live_tv.domain.Match
import com.matchlivetv.match_live_tv.domain.Team
+import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
+import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
@@ -86,6 +88,7 @@ fun MatchesScreen(
var teams by remember { mutableStateOf>(emptyList()) }
var activeTeam by remember { mutableStateOf(null) }
var matches by remember { mutableStateOf>(emptyList()) }
+ var announcements by remember { mutableStateOf>(emptyList()) }
var showNewMatchSheet by remember { mutableStateOf(false) }
var showScheduleSheet by remember { mutableStateOf(false) }
@@ -116,6 +119,7 @@ fun MatchesScreen(
}.onFailure {
error = it.message ?: msgLoadError
}
+ announcements = container.visibleAnnouncements()
loading = false
refreshing = false
}
@@ -192,7 +196,18 @@ fun MatchesScreen(
if (showLanguagePicker) {
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
}
- Box(Modifier.fillMaxSize()) {
+ Column(Modifier.fillMaxSize()) {
+ if (announcements.isNotEmpty()) {
+ AnnouncementBanners(
+ announcements = announcements,
+ onDismiss = { id ->
+ container.announcementStore.dismiss(id)
+ announcements = announcements.filterNot { it.id == id }
+ },
+ modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
+ )
+ }
+ Box(Modifier.weight(1f)) {
PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { reload(showInitialSpinner = false) },
@@ -339,6 +354,7 @@ fun MatchesScreen(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
+ }
}
}
diff --git a/native/android/app/src/main/res/values-de/strings.xml b/native/android/app/src/main/res/values-de/strings.xml
index d6f1af3..50ce74c 100644
--- a/native/android/app/src/main/res/values-de/strings.xml
+++ b/native/android/app/src/main/res/values-de/strings.xml
@@ -14,6 +14,8 @@
Abbrechen
Bestätigen
Speichern
+ Hinweis schließen
+ Öffnen
E-Mail
Passwort
Anmelden
diff --git a/native/android/app/src/main/res/values-en/strings.xml b/native/android/app/src/main/res/values-en/strings.xml
index 2037c1a..9927948 100644
--- a/native/android/app/src/main/res/values-en/strings.xml
+++ b/native/android/app/src/main/res/values-en/strings.xml
@@ -14,6 +14,8 @@
Cancel
Confirm
Save
+ Dismiss notice
+ Open
Email
Password
Log in
diff --git a/native/android/app/src/main/res/values-es/strings.xml b/native/android/app/src/main/res/values-es/strings.xml
index ed090e7..b6cee66 100644
--- a/native/android/app/src/main/res/values-es/strings.xml
+++ b/native/android/app/src/main/res/values-es/strings.xml
@@ -14,6 +14,8 @@
Cancelar
Confirmar
Guardar
+ Cerrar aviso
+ Abrir
Email
Contraseña
Acceder
diff --git a/native/android/app/src/main/res/values-fr/strings.xml b/native/android/app/src/main/res/values-fr/strings.xml
index 7930cf7..b11cfc5 100644
--- a/native/android/app/src/main/res/values-fr/strings.xml
+++ b/native/android/app/src/main/res/values-fr/strings.xml
@@ -14,6 +14,8 @@
Annuler
Confirmer
Enregistrer
+ Fermer l\'alerte
+ Ouvrir
E-mail
Mot de passe
Connexion
diff --git a/native/android/app/src/main/res/values/strings.xml b/native/android/app/src/main/res/values/strings.xml
index 7dc4f16..873c974 100644
--- a/native/android/app/src/main/res/values/strings.xml
+++ b/native/android/app/src/main/res/values/strings.xml
@@ -15,6 +15,8 @@
Annulla
Conferma
Salva
+ Chiudi avviso
+ Apri
Email
Password
Accedi
diff --git a/native/ios/MatchLiveTv/Core/AppLanguage.swift b/native/ios/MatchLiveTv/Core/AppLanguage.swift
index 3c171f0..5d8fea2 100644
--- a/native/ios/MatchLiveTv/Core/AppLanguage.swift
+++ b/native/ios/MatchLiveTv/Core/AppLanguage.swift
@@ -108,6 +108,8 @@ enum L10n {
"api.error.unauthorized": "Sessione scaduta. Accedi di nuovo.",
"app.name": "Match Live TV",
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
+ "announcement.dismiss": "Chiudi avviso",
+ "announcement.open": "Apri",
"broadcast.close.set.button": "CHIUDI SET",
"broadcast.error.camera.preview.not.ready": "Anteprima camera non pronta",
"broadcast.error.permissions.camera.mic": "Permessi camera e microfono richiesti",
@@ -439,6 +441,8 @@ enum L10n {
"api.error.unauthorized": "Session expired. Please sign in again.",
"app.name": "Match Live TV",
"app.slogan": "Every match, every event, for your fans.",
+ "announcement.dismiss": "Dismiss notice",
+ "announcement.open": "Open",
"broadcast.close.set.button": "CLOSE SET",
"broadcast.error.camera.preview.not.ready": "Camera preview not ready",
"broadcast.error.permissions.camera.mic": "Camera and microphone permission required",
@@ -770,6 +774,8 @@ enum L10n {
"api.error.unauthorized": "Session expirée. Connectez-vous à nouveau.",
"app.name": "Match Live TV",
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
+ "announcement.dismiss": "Fermer l'alerte",
+ "announcement.open": "Ouvrir",
"broadcast.close.set.button": "FERMER LE SET",
"broadcast.error.camera.preview.not.ready": "Aperçu caméra non prêt",
"broadcast.error.permissions.camera.mic": "Autorisations caméra et micro requises",
@@ -1101,6 +1107,8 @@ enum L10n {
"api.error.unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.",
"app.name": "Match Live TV",
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
+ "announcement.dismiss": "Hinweis schließen",
+ "announcement.open": "Öffnen",
"broadcast.close.set.button": "SATZ BEENDEN",
"broadcast.error.camera.preview.not.ready": "Kameravorschau nicht bereit",
"broadcast.error.permissions.camera.mic": "Kamera- und Mikrofonberechtigung erforderlich",
@@ -1432,6 +1440,8 @@ enum L10n {
"api.error.unauthorized": "Sesión caducada. Vuelve a iniciar sesión.",
"app.name": "Match Live TV",
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
+ "announcement.dismiss": "Cerrar aviso",
+ "announcement.open": "Abrir",
"broadcast.close.set.button": "CERRAR SET",
"broadcast.error.camera.preview.not.ready": "Vista previa de cámara no lista",
"broadcast.error.permissions.camera.mic": "Se requieren permisos de cámara y micrófono",
diff --git a/native/ios/MatchLiveTv/Core/TokenStore.swift b/native/ios/MatchLiveTv/Core/TokenStore.swift
index 8ba8471..f3ad392 100644
--- a/native/ios/MatchLiveTv/Core/TokenStore.swift
+++ b/native/ios/MatchLiveTv/Core/TokenStore.swift
@@ -109,3 +109,17 @@ final class TokenStore: ObservableObject {
SecItemDelete(query as CFDictionary)
}
}
+
+final class AnnouncementStore {
+ private let key = "mltv.dismissedAnnouncements"
+
+ func dismissedIds() -> Set {
+ Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
+ }
+
+ func dismiss(_ id: String) {
+ var ids = dismissedIds()
+ ids.insert(id)
+ UserDefaults.standard.set(Array(ids), forKey: key)
+ }
+}
diff --git a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift
index b2a8eb3..22f5779 100644
--- a/native/ios/MatchLiveTv/Data/API/ApiDtos.swift
+++ b/native/ios/MatchLiveTv/Data/API/ApiDtos.swift
@@ -41,6 +41,17 @@ struct MessageResponse: Decodable {
let message: String
}
+struct AnnouncementDto: Decodable, Identifiable, Equatable {
+ let id: String
+ let kind: String
+ let severity: String
+ let title: String
+ let body: String
+ let dismissible: Bool
+ let actionUrl: String?
+ let actionLabel: String?
+}
+
struct LoginResponse: Decodable {
let user: UserDto
let accessToken: String
diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
index cc669cf..e9edd0a 100644
--- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
+++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift
@@ -106,6 +106,10 @@ final class MatchLiveAPI: @unchecked Sendable {
try await get("sports")
}
+ func announcements() async throws -> [AnnouncementDto] {
+ try await get("announcements", query: ["platform": "ios"])
+ }
+
func teams() async throws -> [TeamDto] {
try await get("teams")
}
@@ -218,8 +222,8 @@ final class MatchLiveAPI: @unchecked Sendable {
private struct EmptyBody: Encodable {}
- private func get(_ path: String) async throws -> T {
- try await request(path, method: "GET")
+ private func get(_ path: String, query: [String: String] = [:]) async throws -> T {
+ try await request(path, method: "GET", query: query)
}
private func post(_ path: String, body: B) async throws -> T {
@@ -257,9 +261,17 @@ final class MatchLiveAPI: @unchecked Sendable {
private func request(
_ path: String,
method: String,
- body: B? = nil as EmptyBody?
+ body: B? = nil as EmptyBody?,
+ query: [String: String] = [:]
) async throws -> T {
- var request = URLRequest(url: baseURL.appendingPathComponent(path))
+ guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else {
+ throw APIError.invalidURL
+ }
+ if !query.isEmpty {
+ components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
+ }
+ guard let url = components.url else { throw APIError.invalidURL }
+ var request = URLRequest(url: url)
request.httpMethod = method
request.setValue("application/json", forHTTPHeaderField: "Accept")
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
diff --git a/native/ios/MatchLiveTv/Data/AppContainer.swift b/native/ios/MatchLiveTv/Data/AppContainer.swift
index 50e8fbf..d6dd767 100644
--- a/native/ios/MatchLiveTv/Data/AppContainer.swift
+++ b/native/ios/MatchLiveTv/Data/AppContainer.swift
@@ -12,6 +12,7 @@ final class AppContainer: ObservableObject {
let scoreController: ScoreController
let wizardSession = WizardSessionHolder()
let broadcastCoordinator = LiveBroadcastCoordinator()
+ let announcementStore = AnnouncementStore()
init() {
let api = MatchLiveAPI()
@@ -39,4 +40,10 @@ final class AppContainer: ObservableObject {
OverlayLogoCache.configure(token: session.accessToken)
}
}
+
+ func visibleAnnouncements() async -> [AnnouncementDto] {
+ let dismissed = announcementStore.dismissedIds()
+ let all = (try? await api.announcements()) ?? []
+ return all.filter { !dismissed.contains($0.id) }
+ }
}
diff --git a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift
index 5521f56..87c34b0 100644
--- a/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift
+++ b/native/ios/MatchLiveTv/UI/Login/LoginScreen.swift
@@ -12,6 +12,7 @@ struct LoginScreen: View {
@State private var passwordVisible = false
@State private var showLanguagePicker = false
@State private var languageTick = 0
+ @State private var announcements: [AnnouncementDto] = []
var body: some View {
MatchScreenScaffold {
@@ -30,6 +31,16 @@ struct LoginScreen: View {
.padding(.top, 8)
MatchLiveWordmark(showSlogan: true)
.padding(.top, 16)
+ if !announcements.isEmpty {
+ AnnouncementBanners(
+ announcements: announcements,
+ onDismiss: { id in
+ container.announcementStore.dismiss(id)
+ announcements.removeAll { $0.id == id }
+ }
+ )
+ .padding(.top, 24)
+ }
Text(L10n.t("login.submit").uppercased())
.font(MatchTypography.headlineMedium)
.padding(.top, 32)
@@ -73,6 +84,9 @@ struct LoginScreen: View {
languageTick += 1
}
.id(languageTick)
+ .task {
+ announcements = await container.visibleAnnouncements()
+ }
}
private func submitLogin() {
diff --git a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift
index 3fb4e90..7f811bb 100644
--- a/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift
+++ b/native/ios/MatchLiveTv/UI/Matches/MatchesScreen.swift
@@ -22,6 +22,7 @@ struct MatchesScreen: View {
@State private var snackbar: String?
@State private var showLanguagePicker = false
@State private var languageTick = 0
+ @State private var announcements: [AnnouncementDto] = []
var body: some View {
MatchScreenScaffold(
@@ -48,6 +49,18 @@ struct MatchesScreen: View {
.padding(.vertical, 8)
},
content: {
+ VStack(spacing: 0) {
+ if !announcements.isEmpty {
+ AnnouncementBanners(
+ announcements: announcements,
+ onDismiss: { id in
+ container.announcementStore.dismiss(id)
+ announcements.removeAll { $0.id == id }
+ }
+ )
+ .padding(.horizontal, 20)
+ .padding(.top, 8)
+ }
Group {
if loading {
ProgressView().tint(MatchColors.primaryRed)
@@ -135,6 +148,7 @@ struct MatchesScreen: View {
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
+ }
)
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
.alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
@@ -277,6 +291,7 @@ struct MatchesScreen: View {
} catch {
self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error")
}
+ announcements = await container.visibleAnnouncements()
loading = false
refreshing = false
}
@@ -418,6 +433,77 @@ private struct MatchListCard: View {
}
}
+struct AnnouncementBanners: View {
+ let announcements: [AnnouncementDto]
+ let onDismiss: (String) -> Void
+
+ var body: some View {
+ VStack(spacing: 8) {
+ ForEach(announcements) { item in
+ AnnouncementBanner(item: item, onDismiss: { onDismiss(item.id) })
+ }
+ }
+ }
+}
+
+private struct AnnouncementBanner: View {
+ let item: AnnouncementDto
+ let onDismiss: () -> Void
+ @Environment(\.openURL) private var openURL
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 8) {
+ HStack(alignment: .top, spacing: 12) {
+ Image(systemName: iconName)
+ .foregroundStyle(accent)
+ VStack(alignment: .leading, spacing: 4) {
+ Text(item.title)
+ .font(MatchTypography.titleMedium)
+ .foregroundStyle(accent)
+ Text(item.body)
+ .font(MatchTypography.bodyMedium)
+ }
+ Spacer(minLength: 8)
+ if item.dismissible {
+ Button(action: onDismiss) {
+ Image(systemName: "xmark")
+ .foregroundStyle(MatchColors.textSecondary)
+ }
+ .buttonStyle(.plain)
+ .accessibilityLabel(L10n.t("announcement.dismiss"))
+ }
+ }
+ if let urlString = item.actionUrl, let url = URL(string: urlString), !urlString.isEmpty {
+ Button(item.actionLabel?.isEmpty == false ? item.actionLabel! : L10n.t("announcement.open")) {
+ openURL(url)
+ }
+ .font(MatchTypography.labelLarge)
+ .foregroundStyle(accent)
+ }
+ }
+ .padding(14)
+ .background(accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 12))
+ }
+
+ private var accent: Color {
+ if item.severity == "critical" || item.kind == "maintenance" {
+ return MatchColors.accentYellow
+ }
+ if item.kind == "update" {
+ return MatchColors.successGreen
+ }
+ return MatchColors.primaryRed
+ }
+
+ private var iconName: String {
+ switch item.kind {
+ case "maintenance": return "wrench.and.screwdriver.fill"
+ case "update": return "arrow.down.app.fill"
+ default: return "megaphone.fill"
+ }
+ }
+}
+
private struct NewMatchSheet: View {
let onSchedule: () -> Void
let onQuickStart: () -> Void