diff --git a/backend/app/controllers/admin/announcements_controller.rb b/backend/app/controllers/admin/announcements_controller.rb index 9e495ca..32a880e 100644 --- a/backend/app/controllers/admin/announcements_controller.rb +++ b/backend/app/controllers/admin/announcements_controller.rb @@ -57,17 +57,17 @@ module Admin :kind, :severity, :status, :title, :body, :action_url, :action_label, :dismissible, :starts_at, :ends_at, :show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private, - :android_title, :android_body, :android_action_url, :android_action_label, - :ios_title, :ios_body, :ios_action_url, :ios_action_label + translations: { + en: AppAnnouncement::COPY_KEYS, + fr: AppAnnouncement::COPY_KEYS, + de: AppAnnouncement::COPY_KEYS, + es: AppAnnouncement::COPY_KEYS + } ).tap do |permitted| %i[dismissible show_on_android show_on_ios show_on_web_public show_on_web_private].each do |key| permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key]) end - %i[ - starts_at ends_at action_url action_label - android_title android_body android_action_url android_action_label - ios_title ios_body ios_action_url ios_action_label - ].each do |key| + %i[starts_at ends_at action_url action_label].each do |key| permitted[key] = nil if permitted[key].blank? end end diff --git a/backend/app/models/app_announcement.rb b/backend/app/models/app_announcement.rb index a64df1a..6318b45 100644 --- a/backend/app/models/app_announcement.rb +++ b/backend/app/models/app_announcement.rb @@ -8,7 +8,18 @@ class AppAnnouncement < ApplicationRecord web_public: :show_on_web_public, web_private: :show_on_web_private }.freeze - URL_FIELDS = %i[action_url android_action_url ios_action_url].freeze + COPY_KEYS = %w[title body action_url action_label].freeze + URL_KEYS = %w[action_url].freeze + TITLE_KEYS = %w[title].freeze + BODY_KEYS = %w[body].freeze + LABEL_KEYS = %w[action_label].freeze + LOCALE_FALLBACKS = { + "it" => %w[it], + "en" => %w[en it], + "fr" => %w[fr en it], + "de" => %w[de en it], + "es" => %w[es en it] + }.freeze belongs_to :created_by_admin, class_name: "AdminAccount", optional: true @@ -16,14 +27,16 @@ class AppAnnouncement < ApplicationRecord validates :kind, inclusion: { in: KINDS } validates :severity, inclusion: { in: SEVERITIES } validates :status, inclusion: { in: STATUSES } - validates :title, :android_title, :ios_title, length: { maximum: 120 }, allow_blank: true - validates :body, :android_body, :ios_body, length: { maximum: 2000 }, allow_blank: true - validates :action_label, :android_action_label, :ios_action_label, length: { maximum: 40 }, allow_blank: true - URL_FIELDS.each do |field| + validates :title, length: { maximum: 120 }, allow_blank: true + validates :body, length: { maximum: 2000 }, allow_blank: true + validates :action_label, length: { maximum: 40 }, allow_blank: true + URL_KEYS.each do |field| validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true } end validate :ends_at_after_starts_at validate :at_least_one_channel + validate :translations_are_valid + before_validation :normalize_translations scope :newest, -> { order(created_at: :desc) } scope :published, -> { where(status: "published") } @@ -63,25 +76,49 @@ class AppAnnouncement < ApplicationRecord end end - def as_api_json(platform: nil) + def copy_values_for(locale) + loc = locale.to_s + if default_locale?(loc) + COPY_KEYS.index_with { |key| public_send(key).to_s.presence } + else + translation_hash(loc) + end + end + + def filled_locale_codes + LocaleResolver.available.select { |loc| locale_has_copy?(loc) }.map(&:to_s) + end + + def as_api_json(platform: nil, locale: I18n.locale) { id: id, kind: kind, severity: severity, - title: copy_for(platform, :title), - body: copy_for(platform, :body), + title: copy_for("title", locale: locale), + body: copy_for("body", locale: locale), dismissible: dismissible, - action_url: resolved_action_url(platform), - action_label: copy_for(platform, :action_label).presence, + action_url: resolved_action_url(platform, locale: locale), + action_label: copy_for("action_label", locale: locale).presence, starts_at: starts_at&.iso8601, ends_at: ends_at&.iso8601 } end - def resolved_action_url(platform) - override = platform_override(platform, :action_url) - return override if override.present? - return action_url if action_url.present? + def localized_title(locale: I18n.locale) + copy_for("title", locale: locale) + end + + def localized_body(locale: I18n.locale) + copy_for("body", locale: locale) + end + + def localized_action_label(locale: I18n.locale) + copy_for("action_label", locale: locale) + end + + def resolved_action_url(platform, locale: I18n.locale) + url = copy_for("action_url", locale: locale) + return url if url.present? return nil unless kind == "update" case platform.to_s @@ -92,21 +129,73 @@ class AppAnnouncement < ApplicationRecord private - def copy_for(platform, field) - override = platform_override(platform, field) - override.presence || public_send(field) + def copy_for(field, locale: I18n.locale) + field = field.to_s + locale_chain(locale).each do |loc| + value = value_for(loc, field) + return value if value.present? + end + nil end - def platform_override(platform, field) - prefix = case platform.to_s - when "android" then :android - when "ios" then :ios - end - return if prefix.blank? - - public_send("#{prefix}_#{field}") + def value_for(locale, key) + copy_values_for(locale)[key].presence end + def translation_hash(locale) + raw = translations.is_a?(Hash) ? translations : {} + values = raw.stringify_keys[locale.to_s] + return {} unless values.is_a?(Hash) + + values.stringify_keys + end + + def locale_chain(locale) + loc = (LocaleResolver.normalize(locale) || I18n.default_locale).to_s + LOCALE_FALLBACKS[loc] || [loc, I18n.default_locale.to_s].uniq + end + + def locale_has_copy?(locale) + values = copy_values_for(locale) + values["title"].present? || values["body"].present? + end + + def default_locale?(locale) + locale.to_s == I18n.default_locale.to_s + end + + def normalize_translations + self.translations = self.class.sanitize_translations(translations) + end + + def self.sanitize_translations(value) + raw = coerce_translation_hash(value) + LocaleResolver.available.each_with_object({}) do |loc, acc| + next if loc.to_s == I18n.default_locale.to_s + + payload = raw[loc.to_s] + next unless payload.is_a?(Hash) + + cleaned = COPY_KEYS.each_with_object({}) do |key, fields| + text = payload[key].to_s.strip + fields[key] = text if text.present? + end + acc[loc.to_s] = cleaned if cleaned.any? + end + end + + def self.coerce_translation_hash(value) + hash = if value.is_a?(ActionController::Parameters) + value.to_unsafe_h + elsif value.is_a?(Hash) + value + else + {} + end + hash.deep_stringify_keys + end + private_class_method :coerce_translation_hash + def at_least_one_channel return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private? @@ -119,4 +208,33 @@ class AppAnnouncement < ApplicationRecord errors.add(:ends_at, :invalid) end + + def translations_are_valid + translation_entries.each do |locale, payload| + TITLE_KEYS.each do |key| + errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 120 + end + BODY_KEYS.each do |key| + errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 2000 + end + LABEL_KEYS.each do |key| + errors.add(:base, :translation_too_long, locale: locale, field: key) if payload[key].to_s.length > 40 + end + URL_KEYS.each do |key| + url = payload[key].to_s + next if url.blank? || url.match?(/\Ahttps?:\/\/.+/i) + + errors.add(:base, :translation_invalid_url, locale: locale) + end + end + end + + def translation_entries + raw = translations.is_a?(Hash) ? translations : {} + raw.stringify_keys.filter_map do |locale, payload| + next unless payload.is_a?(Hash) + + [locale, payload.stringify_keys] + end + end end diff --git a/backend/app/views/admin/announcements/_form.html.erb b/backend/app/views/admin/announcements/_form.html.erb index 1e6a9bb..463d5ff 100644 --- a/backend/app/views/admin/announcements/_form.html.erb +++ b/backend/app/views/admin/announcements/_form.html.erb @@ -37,67 +37,34 @@

<%= t("admin.announcements.form.channels_hint") %>

-
- <%= f.label :title, t("admin.announcements.form.title") %> - <%= f.text_field :title, required: true, maxlength: 120 %> -
- -
- <%= f.label :body, t("admin.announcements.form.body") %> - <%= f.text_area :body, required: true, rows: 5, maxlength: 2000 %> -
- -
- <%= f.label :action_url, t("admin.announcements.form.action_url") %> - <%= f.url_field :action_url, placeholder: "https://" %> -
-

<%= t("admin.announcements.form.action_url_hint") %>

- -
- <%= f.label :action_label, t("admin.announcements.form.action_label") %> - <%= f.text_field :action_label, maxlength: 40 %> -
- -
- <%= t("admin.announcements.form.android_override") %> -

<%= t("admin.announcements.form.platform_override_hint") %>

-
- <%= f.label :android_title, t("admin.announcements.form.title") %> - <%= f.text_field :android_title, maxlength: 120 %> -
-
- <%= f.label :android_body, t("admin.announcements.form.body") %> - <%= f.text_area :android_body, rows: 4, maxlength: 2000 %> -
-
- <%= f.label :android_action_url, t("admin.announcements.form.action_url") %> - <%= f.url_field :android_action_url, placeholder: "https://play.google.com/..." %> -
-
- <%= f.label :android_action_label, t("admin.announcements.form.action_label") %> - <%= f.text_field :android_action_label, maxlength: 40 %> -
-
- -
- <%= t("admin.announcements.form.ios_override") %> -

<%= t("admin.announcements.form.platform_override_hint") %>

-
- <%= f.label :ios_title, t("admin.announcements.form.title") %> - <%= f.text_field :ios_title, maxlength: 120 %> -
-
- <%= f.label :ios_body, t("admin.announcements.form.body") %> - <%= f.text_area :ios_body, rows: 4, maxlength: 2000 %> -
-
- <%= f.label :ios_action_url, t("admin.announcements.form.action_url") %> - <%= f.url_field :ios_action_url, placeholder: "https://apps.apple.com/..." %> -
-
- <%= f.label :ios_action_label, t("admin.announcements.form.action_label") %> - <%= f.text_field :ios_action_label, maxlength: 40 %> +
+ <%= t("admin.announcements.form.locales_legend") %> +

<%= t("admin.announcements.form.locales_hint") %>

+
+ <% language_options.each do |opt| %> + + <% end %>
+ <% language_options.each do |opt| %> +
> + <%= render "locale_copy", f: f, announcement: announcement, locale: opt[:code] %> +
+ <% end %>
@@ -123,3 +90,23 @@ <%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
<% end %> + + diff --git a/backend/app/views/admin/announcements/_locale_copy.html.erb b/backend/app/views/admin/announcements/_locale_copy.html.erb new file mode 100644 index 0000000..22aee6e --- /dev/null +++ b/backend/app/views/admin/announcements/_locale_copy.html.erb @@ -0,0 +1,44 @@ +<% it = locale.to_s == I18n.default_locale.to_s %> +<% values = announcement.copy_values_for(locale) %> +<% name_base = "app_announcement[translations][#{locale}]" %> + +
+ <% if it %> + <%= f.label :title, t("admin.announcements.form.title") %> + <%= f.text_field :title, required: true, maxlength: 120, id: "ann_#{locale}_title" %> + <% else %> + + <%= text_field_tag "#{name_base}[title]", values["title"], maxlength: 120, id: "ann_#{locale}_title" %> + <% end %> +
+ +
+ <% if it %> + <%= f.label :body, t("admin.announcements.form.body") %> + <%= f.text_area :body, required: true, rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %> + <% else %> + + <%= text_area_tag "#{name_base}[body]", values["body"], rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %> + <% end %> +
+ +
+ <% if it %> + <%= f.label :action_url, t("admin.announcements.form.action_url") %> + <%= f.url_field :action_url, placeholder: "https://", id: "ann_#{locale}_action_url" %> + <% else %> + + <%= url_field_tag "#{name_base}[action_url]", values["action_url"], placeholder: "https://", id: "ann_#{locale}_action_url" %> + <% end %> +
+

<%= t("admin.announcements.form.action_url_hint") %>

+ +
+ <% if it %> + <%= f.label :action_label, t("admin.announcements.form.action_label") %> + <%= f.text_field :action_label, maxlength: 40, id: "ann_#{locale}_action_label" %> + <% else %> + + <%= text_field_tag "#{name_base}[action_label]", values["action_label"], maxlength: 40, id: "ann_#{locale}_action_label" %> + <% end %> +
diff --git a/backend/app/views/admin/announcements/index.html.erb b/backend/app/views/admin/announcements/index.html.erb index 3ea3d1f..e6258c0 100644 --- a/backend/app/views/admin/announcements/index.html.erb +++ b/backend/app/views/admin/announcements/index.html.erb @@ -36,6 +36,7 @@ <%= item.title %>
<%= truncate(item.body, length: 90) %>
+
<%= item.filled_locale_codes.map(&:upcase).join(" · ") %>
<%= announcement_channels_label(item) %> <%= announcement_window_label(item) %> diff --git a/backend/app/views/layouts/admin.html.erb b/backend/app/views/layouts/admin.html.erb index 30cf6cd..1b797c3 100644 --- a/backend/app/views/layouts/admin.html.erb +++ b/backend/app/views/layouts/admin.html.erb @@ -4,7 +4,7 @@ <%= t("admin.layout.title") %> - + <% if content_for?(:replay_archive_styles) %> <% end %> diff --git a/backend/app/views/shared/_site_announcements.html.erb b/backend/app/views/shared/_site_announcements.html.erb index d0f6f61..cc8258b 100644 --- a/backend/app/views/shared/_site_announcements.html.erb +++ b/backend/app/views/shared/_site_announcements.html.erb @@ -5,11 +5,12 @@
-

<%= 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",