-
<%= item.title %>
-
<%= simple_format(item.body) %>
- <% if item.action_url.present? %>
+
<%= item.localized_title %>
+
<%= simple_format(item.localized_body) %>
+ <% action_url = item.resolved_action_url(nil) %>
+ <% if action_url.present? %>
- <%= link_to (item.action_label.presence || t("announcement.open")), item.action_url,
+ <%= link_to (item.localized_action_label.presence || t("announcement.open")), action_url,
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
<% end %>
diff --git a/backend/config/locales/admin.de.yml b/backend/config/locales/admin.de.yml
index ed5dab1..daaf314 100644
--- a/backend/config/locales/admin.de.yml
+++ b/backend/config/locales/admin.de.yml
@@ -421,18 +421,18 @@ de:
severity: Schwere
status: Status
channels: Wo anzeigen
- channels_hint: Eine oder mehrere Ziele wählen. Android und iOS sind unabhängig. Der Login-Bereich ist nach der Anmeldung sichtbar (Verein, Teams, Konto).
+ channels_hint: "Eine oder mehrere Ziele wählen. Android und iOS sind unabhängig: bei unterschiedlichem Text zwei Hinweise anlegen. 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 der Plattform. Unten können Titel, Text und Link für Android und iOS unterschiedlich sein.
+ action_url_hint: Bei Updates öffnet eine leere URL den Store der Plattform (Play Store auf Android, App Store auf iOS).
action_label: Button-Text
- android_override: Nur Android (optional)
- ios_override: Nur iOS (optional)
- platform_override_hint: Leer lassen, um gemeinsame Texte und Links zu nutzen. Nur ausfüllen, wenn diese Plattform etwas anderes sehen soll.
+ locales_legend: Text pro Sprache
+ locales_hint: Alle App-Sprachen ausfüllen (Italienisch, Englisch, Französisch, Deutsch, Spanisch). Italienisch ist Pflicht; fehlende Sprachen fallen auf Englisch, dann Italienisch zurück.
+ locale_required: Pflicht
dismissible: Nutzer können den Hinweis schließen
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
submit_create: Hinweis speichern
diff --git a/backend/config/locales/admin.en.yml b/backend/config/locales/admin.en.yml
index 1d790ac..67839e5 100644
--- a/backend/config/locales/admin.en.yml
+++ b/backend/config/locales/admin.en.yml
@@ -421,18 +421,18 @@ en:
severity: Severity
status: Status
channels: Where to show it
- channels_hint: Select one or more destinations. Android and iOS are independent. The logged-in area is visible after sign-in (club, teams, account).
+ channels_hint: "Select one or more destinations. Android and iOS are independent: create two notices if the copy should differ. 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 that platform's store. Below you can set a different title, body and link for Android and iOS.
+ action_url_hint: For updates, an empty URL opens that platform's store (Play Store on Android, App Store on iOS).
action_label: Button label
- android_override: Android only (optional)
- ios_override: iOS only (optional)
- platform_override_hint: Leave empty to use the shared title, body and link. Fill in only if this platform should see different copy or a different URL.
+ locales_legend: Copy by language
+ locales_hint: Fill in every app language (Italian, English, French, German, Spanish). Italian is required; missing languages fall back to English, then Italian.
+ locale_required: required
dismissible: Users can dismiss the notice
dismissible_hint: Uncheck for maintenance notices that should stay visible.
submit_create: Save notice
diff --git a/backend/config/locales/admin.es.yml b/backend/config/locales/admin.es.yml
index 963c9a1..47f3e8b 100644
--- a/backend/config/locales/admin.es.yml
+++ b/backend/config/locales/admin.es.yml
@@ -421,18 +421,18 @@ es:
severity: Gravedad
status: Estado
channels: Dónde mostrarlo
- channels_hint: Elige uno o más destinos. Android e iOS son independientes. El área privada se ve tras iniciar sesión (club, equipos, cuenta).
+ channels_hint: "Elige uno o más destinos. Android e iOS son independientes: si el texto debe ser distinto, crea dos avisos. 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 de esa plataforma. Abajo puedes poner título, texto y enlace distintos para Android e iOS.
+ action_url_hint: En actualizaciones, una URL vacía abre la tienda de esa plataforma (Play Store en Android, App Store en iOS).
action_label: Texto del botón
- android_override: Solo Android (opcional)
- ios_override: Solo iOS (opcional)
- platform_override_hint: Déjalo vacío para usar el título, texto y enlace comunes. Rellena solo si esta plataforma debe ver un mensaje o un enlace distinto.
+ locales_legend: Texto por idioma
+ locales_hint: Completa todos los idiomas de la app (italiano, inglés, francés, alemán, español). El italiano es obligatorio; si falta un idioma, se usa inglés y luego italiano.
+ locale_required: obligatorio
dismissible: El usuario puede cerrar el aviso
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
submit_create: Guardar aviso
diff --git a/backend/config/locales/admin.fr.yml b/backend/config/locales/admin.fr.yml
index 26c41dd..9395087 100644
--- a/backend/config/locales/admin.fr.yml
+++ b/backend/config/locales/admin.fr.yml
@@ -421,18 +421,18 @@ fr:
severity: Gravité
status: Statut
channels: Où l'afficher
- channels_hint: Choisissez une ou plusieurs destinations. Android et iOS sont indépendants. L'espace connecté est visible après connexion (club, équipes, compte).
+ channels_hint: "Choisissez une ou plusieurs destinations. Android et iOS sont indépendants : créez deux alertes si le texte doit différer. 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 de la plateforme. Ci-dessous vous pouvez définir titre, texte et lien différents pour Android et iOS.
+ action_url_hint: Pour une mise à jour, une URL vide ouvre le store de la plateforme (Play Store sur Android, App Store sur iOS).
action_label: Libellé du bouton
- android_override: Android uniquement (facultatif)
- ios_override: iOS uniquement (facultatif)
- platform_override_hint: Laissez vide pour utiliser le titre, le texte et le lien communs. Remplissez seulement si cette plateforme doit voir un message ou un lien différent.
+ locales_legend: Texte par langue
+ locales_hint: Renseignez toutes les langues de l'app (italien, anglais, français, allemand, espagnol). L'italien est obligatoire ; les langues manquantes basculent vers l'anglais puis l'italien.
+ locale_required: obligatoire
dismissible: L'utilisateur peut fermer l'alerte
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
submit_create: Enregistrer l'alerte
diff --git a/backend/config/locales/admin.it.yml b/backend/config/locales/admin.it.yml
index 30d96cb..3b9e403 100644
--- a/backend/config/locales/admin.it.yml
+++ b/backend/config/locales/admin.it.yml
@@ -459,18 +459,18 @@ it:
severity: Gravità
status: Stato
channels: Dove mostrarlo
- channels_hint: Puoi selezionare una o più destinazioni. Android e iOS sono indipendenti. L'area riservata è visibile dopo il login (società, squadre, account).
+ channels_hint: "Puoi selezionare una o più destinazioni. Android e iOS sono indipendenti: se il testo deve essere diverso, crea due avvisi. 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 della piattaforma. Sotto puoi impostare titolo, testo e link diversi per Android e iOS.
+ action_url_hint: Per gli aggiornamenti, se vuoto l'app apre lo store della piattaforma (Play Store su Android, App Store su iOS).
action_label: Etichetta del pulsante
- android_override: Solo Android (facoltativo)
- ios_override: Solo iOS (facoltativo)
- platform_override_hint: Lascia vuoto per usare titolo, testo e link comuni. Compila solo se questa piattaforma deve vedere un messaggio o un link diverso.
+ locales_legend: Testo per lingua
+ locales_hint: Compila tutte le lingue dell'app (italiano, inglese, francese, tedesco, spagnolo). L'italiano è obbligatorio; se una lingua manca, l'app usa inglese e poi italiano.
+ locale_required: obbligatorio
dismissible: L'utente può chiudere l'avviso
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
submit_create: Salva avviso
diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml
index 2ab4d9a..8fd3076 100644
--- a/backend/config/locales/app.de.yml
+++ b/backend/config/locales/app.de.yml
@@ -23,6 +23,8 @@ de:
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).
+ translation_too_long: "Übersetzung %{locale}: ein Feld ist zu lang."
+ translation_invalid_url: "Übersetzung %{locale}: der Link muss eine http(s)-URL sein."
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 9ee2237..132dcdf 100644
--- a/backend/config/locales/app.en.yml
+++ b/backend/config/locales/app.en.yml
@@ -18,6 +18,8 @@ en:
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).
+ translation_too_long: "Translation %{locale}: a field is too long."
+ translation_invalid_url: "Translation %{locale}: the link must be an http(s) URL."
club:
back_to_club: "← Club"
sport_label: Main sport
diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml
index 3db0566..a6f1902 100644
--- a/backend/config/locales/app.es.yml
+++ b/backend/config/locales/app.es.yml
@@ -23,6 +23,8 @@ es:
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).
+ translation_too_long: "Traducción %{locale}: un campo es demasiado largo."
+ translation_invalid_url: "Traducción %{locale}: el enlace debe ser una URL http(s)."
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 9b97e67..99cff0e 100644
--- a/backend/config/locales/app.fr.yml
+++ b/backend/config/locales/app.fr.yml
@@ -23,6 +23,8 @@ fr:
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é).
+ translation_too_long: "Traduction %{locale} : un champ est trop long."
+ translation_invalid_url: "Traduction %{locale} : le lien doit être une URL http(s)."
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 dde4c16..8efa8df 100644
--- a/backend/config/locales/app.it.yml
+++ b/backend/config/locales/app.it.yml
@@ -18,6 +18,8 @@ it:
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).
+ translation_too_long: "Traduzione %{locale}: un campo supera la lunghezza massima."
+ translation_invalid_url: "Traduzione %{locale}: il link deve essere un URL http(s)."
club:
back_to_club: "← Società"
sport_label: Sport principale
diff --git a/backend/db/migrate/20260820190000_add_announcement_translations.rb b/backend/db/migrate/20260820190000_add_announcement_translations.rb
new file mode 100644
index 0000000..bea31e6
--- /dev/null
+++ b/backend/db/migrate/20260820190000_add_announcement_translations.rb
@@ -0,0 +1,5 @@
+class AddAnnouncementTranslations < ActiveRecord::Migration[7.2]
+ def change
+ add_column :app_announcements, :translations, :jsonb, null: false, default: {}
+ end
+end
diff --git a/backend/db/migrate/20260820191000_remove_announcement_platform_copy.rb b/backend/db/migrate/20260820191000_remove_announcement_platform_copy.rb
new file mode 100644
index 0000000..f37ef2e
--- /dev/null
+++ b/backend/db/migrate/20260820191000_remove_announcement_platform_copy.rb
@@ -0,0 +1,12 @@
+class RemoveAnnouncementPlatformCopy < ActiveRecord::Migration[7.2]
+ def change
+ remove_column :app_announcements, :android_title, :string
+ remove_column :app_announcements, :android_body, :text
+ remove_column :app_announcements, :android_action_url, :string
+ remove_column :app_announcements, :android_action_label, :string
+ remove_column :app_announcements, :ios_title, :string
+ remove_column :app_announcements, :ios_body, :text
+ remove_column :app_announcements, :ios_action_url, :string
+ remove_column :app_announcements, :ios_action_label, :string
+ end
+end
diff --git a/backend/db/schema.rb b/backend/db/schema.rb
index f886449..7b959ca 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_20_180000) do
+ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@@ -69,14 +69,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_20_180000) do
t.datetime "updated_at", null: false
t.boolean "show_on_android", default: true, null: false
t.boolean "show_on_ios", default: true, null: false
- t.string "android_title"
- t.text "android_body"
- t.string "android_action_url"
- t.string "android_action_label"
- t.string "ios_title"
- t.text "ios_body"
- t.string "ios_action_url"
- t.string "ios_action_label"
+ t.jsonb "translations", default: {}, 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"
diff --git a/backend/public/admin.css b/backend/public/admin.css
index fdc3b1f..9341136 100644
--- a/backend/public/admin.css
+++ b/backend/public/admin.css
@@ -670,15 +670,6 @@ body.admin-body {
color: var(--muted);
}
-.admin-platform-copy {
- margin-top: 0.35rem;
-}
-
-.admin-platform-copy > div {
- display: grid;
- gap: 0.25rem;
-}
-
.admin-locale-tabs {
display: flex;
flex-wrap: wrap;
diff --git a/backend/spec/models/app_announcement_spec.rb b/backend/spec/models/app_announcement_spec.rb
index bb9ad16..c3b1f97 100644
--- a/backend/spec/models/app_announcement_spec.rb
+++ b/backend/spec/models/app_announcement_spec.rb
@@ -51,33 +51,22 @@ RSpec.describe AppAnnouncement do
expect(described_class.for_app).to contain_exactly(android_item, ios_item)
end
- it "uses platform-specific copy and links when present" do
+ it "serves translated copy and falls back to English then Italian" do
item = create_notice!(
- title: "Comune",
- body: "Testo comune",
- action_url: "https://example.com/comune",
- action_label: "Apri",
- android_title: "Aggiorna su Android",
- android_body: "Apri Play Store",
- android_action_url: "https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv",
- android_action_label: "Play Store",
- ios_title: "Aggiorna su iOS",
- ios_body: "Apri App Store",
- ios_action_url: "https://apps.apple.com/app/id000",
- ios_action_label: "App Store"
+ title: "Titolo IT",
+ body: "Testo IT",
+ translations: {
+ "en" => { "title" => "EN title", "body" => "EN body" },
+ "de" => { "title" => "DE title", "body" => "DE body" }
+ }
)
- android = item.as_api_json(platform: "android")
- ios = item.as_api_json(platform: "ios")
-
- expect(android[:title]).to eq("Aggiorna su Android")
- expect(android[:body]).to eq("Apri Play Store")
- expect(android[:action_url]).to include("play.google.com")
- expect(android[:action_label]).to eq("Play Store")
- expect(ios[:title]).to eq("Aggiorna su iOS")
- expect(ios[:body]).to eq("Apri App Store")
- expect(ios[:action_url]).to include("apps.apple.com")
- expect(ios[:action_label]).to eq("App Store")
+ expect(item.as_api_json(locale: :en)[:title]).to eq("EN title")
+ expect(item.as_api_json(locale: :de)[:title]).to eq("DE title")
+ expect(item.as_api_json(locale: :fr)[:title]).to eq("EN title")
+ expect(item.as_api_json(locale: :it)[:title]).to eq("Titolo IT")
+ expect(item.filled_locale_codes).to include("it", "en", "de")
+ expect(item.filled_locale_codes).not_to include("fr")
end
it "requires at least one channel" do
diff --git a/backend/spec/requests/admin/announcements_spec.rb b/backend/spec/requests/admin/announcements_spec.rb
index 5bef892..605ba3f 100644
--- a/backend/spec/requests/admin/announcements_spec.rb
+++ b/backend/spec/requests/admin/announcements_spec.rb
@@ -39,6 +39,42 @@ RSpec.describe "Admin announcements", type: :request do
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
end
+ it "salva le traduzioni e mostra le schede lingua nel form" do
+ get new_admin_announcement_path
+ expect(response.body).to include(I18n.t("admin.announcements.form.locales_legend"))
+ expect(response.body).to include("English")
+ expect(response.body).to include("Français")
+ expect(response.body).to include("Deutsch")
+ expect(response.body).to include("Español")
+ expect(response.body).not_to include("name=\"app_announcement[android_title]\"")
+ expect(response.body).not_to include("name=\"app_announcement[ios_title]\"")
+
+ post admin_announcements_path, params: {
+ app_announcement: {
+ kind: "info",
+ severity: "info",
+ status: "published",
+ title: "Nuova versione",
+ body: "Aggiorna l'app dallo store.",
+ dismissible: "1",
+ show_on_android: "1",
+ show_on_ios: "1",
+ show_on_web_public: "1",
+ show_on_web_private: "0",
+ translations: {
+ en: { title: "A new version is out", body: "Update the app from the store." },
+ fr: { title: "Nouvelle version", body: "Mettez à jour l'application." }
+ }
+ }
+ }
+
+ expect(response).to redirect_to(admin_announcements_path)
+ notice = AppAnnouncement.last
+ expect(notice.translations["en"]["title"]).to eq("A new version is out")
+ expect(notice.translations["fr"]["title"]).to eq("Nouvelle version")
+ expect(notice.as_api_json(locale: :en)[:title]).to eq("A new version is out")
+ end
+
it "elimina un avviso" do
item = AppAnnouncement.create!(
kind: "info", status: "published", title: "Da togliere", body: "Test"
diff --git a/backend/spec/requests/api/v1/announcements_spec.rb b/backend/spec/requests/api/v1/announcements_spec.rb
index 4322761..0728121 100644
--- a/backend/spec/requests/api/v1/announcements_spec.rb
+++ b/backend/spec/requests/api/v1/announcements_spec.rb
@@ -31,12 +31,10 @@ RSpec.describe "Api::V1::Announcements", type: :request do
AppAnnouncement.create!(
kind: "update",
status: "published",
- title: "Aggiorna",
+ title: "Aggiorna Android",
body: "Versione nuova",
show_on_android: true,
- show_on_ios: false,
- android_title: "Aggiorna Android",
- android_action_url: "https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv"
+ show_on_ios: false
)
get "/api/v1/announcements", params: { platform: "android" }
@@ -51,4 +49,29 @@ RSpec.describe "Api::V1::Announcements", type: :request do
ios_body = JSON.parse(response.body)
expect(ios_body.map { |row| row["title"] }).to eq(["Manutenzione stasera"])
end
+
+ it "GET /api/v1/announcements localizza il testo da Accept-Language" do
+ AppAnnouncement.create!(
+ kind: "info",
+ status: "published",
+ title: "Manutenzione stasera",
+ body: "Dalle 23:00 il servizio sarà offline.",
+ show_on_android: true,
+ show_on_ios: true,
+ translations: {
+ "en" => { "title" => "Maintenance tonight", "body" => "The service will be offline from 23:00." }
+ }
+ )
+
+ get "/api/v1/announcements", params: { platform: "android" }, headers: { "Accept-Language" => "en-US,en;q=0.9" }
+
+ expect(response).to have_http_status(:ok)
+ payload = JSON.parse(response.body)
+ expect(payload.first["title"]).to eq("Maintenance tonight")
+ expect(payload.first["body"]).to include("offline")
+
+ get "/api/v1/announcements", params: { platform: "android" }, headers: { "Accept-Language" => "fr" }
+
+ expect(JSON.parse(response.body).first["title"]).to eq("Maintenance tonight")
+ end
end
diff --git a/backend/spec/requests/public/announcements_spec.rb b/backend/spec/requests/public/announcements_spec.rb
index 7aa845c..73cad65 100644
--- a/backend/spec/requests/public/announcements_spec.rb
+++ b/backend/spec/requests/public/announcements_spec.rb
@@ -26,6 +26,24 @@ RSpec.describe "Public site announcements", type: :request do
expect(response.body).not_to include("Solo soci")
end
+ it "mostra la traduzione inglese sul sito pubblico" do
+ AppAnnouncement.create!(
+ kind: "maintenance", status: "published", title: "Manutenzione sito",
+ body: "Stasera il sito sarà offline.",
+ show_on_android: false, show_on_ios: false, show_on_web_public: true, show_on_web_private: false,
+ translations: {
+ "en" => { "title" => "Website maintenance", "body" => "The site will be offline tonight." }
+ }
+ )
+
+ cookies[:mltv_locale] = "en"
+ get root_path
+
+ expect(response).to have_http_status(:ok)
+ expect(response.body).to include("Website maintenance")
+ expect(response.body).not_to include("Manutenzione sito")
+ end
+
it "mostra gli avvisi privati nell'area account dopo il login" do
AppAnnouncement.create!(
kind: "info", status: "published", title: "Solo soci",