Compare commits
2
Commits
a07f231417
...
f02782d0f0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02782d0f0 | ||
|
|
fb82b74e92 |
@@ -57,17 +57,17 @@ module Admin
|
|||||||
:kind, :severity, :status, :title, :body,
|
:kind, :severity, :status, :title, :body,
|
||||||
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
|
:action_url, :action_label, :dismissible, :starts_at, :ends_at,
|
||||||
:show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
|
:show_on_android, :show_on_ios, :show_on_web_public, :show_on_web_private,
|
||||||
:android_title, :android_body, :android_action_url, :android_action_label,
|
translations: {
|
||||||
:ios_title, :ios_body, :ios_action_url, :ios_action_label
|
en: AppAnnouncement::COPY_KEYS,
|
||||||
|
fr: AppAnnouncement::COPY_KEYS,
|
||||||
|
de: AppAnnouncement::COPY_KEYS,
|
||||||
|
es: AppAnnouncement::COPY_KEYS
|
||||||
|
}
|
||||||
).tap do |permitted|
|
).tap do |permitted|
|
||||||
%i[dismissible show_on_android show_on_ios show_on_web_public show_on_web_private].each do |key|
|
%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])
|
permitted[key] = ActiveModel::Type::Boolean.new.cast(permitted[key])
|
||||||
end
|
end
|
||||||
%i[
|
%i[starts_at ends_at action_url action_label].each do |key|
|
||||||
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|
|
|
||||||
permitted[key] = nil if permitted[key].blank?
|
permitted[key] = nil if permitted[key].blank?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -8,7 +8,18 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
web_public: :show_on_web_public,
|
web_public: :show_on_web_public,
|
||||||
web_private: :show_on_web_private
|
web_private: :show_on_web_private
|
||||||
}.freeze
|
}.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
|
belongs_to :created_by_admin, class_name: "AdminAccount", optional: true
|
||||||
|
|
||||||
@@ -16,14 +27,16 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
validates :kind, inclusion: { in: KINDS }
|
validates :kind, inclusion: { in: KINDS }
|
||||||
validates :severity, inclusion: { in: SEVERITIES }
|
validates :severity, inclusion: { in: SEVERITIES }
|
||||||
validates :status, inclusion: { in: STATUSES }
|
validates :status, inclusion: { in: STATUSES }
|
||||||
validates :title, :android_title, :ios_title, length: { maximum: 120 }, allow_blank: true
|
validates :title, length: { maximum: 120 }, allow_blank: true
|
||||||
validates :body, :android_body, :ios_body, length: { maximum: 2000 }, allow_blank: true
|
validates :body, length: { maximum: 2000 }, allow_blank: true
|
||||||
validates :action_label, :android_action_label, :ios_action_label, length: { maximum: 40 }, allow_blank: true
|
validates :action_label, length: { maximum: 40 }, allow_blank: true
|
||||||
URL_FIELDS.each do |field|
|
URL_KEYS.each do |field|
|
||||||
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
|
validates field, format: { with: /\Ahttps?:\/\/.+/i, allow_blank: true }
|
||||||
end
|
end
|
||||||
validate :ends_at_after_starts_at
|
validate :ends_at_after_starts_at
|
||||||
validate :at_least_one_channel
|
validate :at_least_one_channel
|
||||||
|
validate :translations_are_valid
|
||||||
|
before_validation :normalize_translations
|
||||||
|
|
||||||
scope :newest, -> { order(created_at: :desc) }
|
scope :newest, -> { order(created_at: :desc) }
|
||||||
scope :published, -> { where(status: "published") }
|
scope :published, -> { where(status: "published") }
|
||||||
@@ -63,25 +76,49 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
end
|
end
|
||||||
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,
|
id: id,
|
||||||
kind: kind,
|
kind: kind,
|
||||||
severity: severity,
|
severity: severity,
|
||||||
title: copy_for(platform, :title),
|
title: copy_for("title", locale: locale),
|
||||||
body: copy_for(platform, :body),
|
body: copy_for("body", locale: locale),
|
||||||
dismissible: dismissible,
|
dismissible: dismissible,
|
||||||
action_url: resolved_action_url(platform),
|
action_url: resolved_action_url(platform, locale: locale),
|
||||||
action_label: copy_for(platform, :action_label).presence,
|
action_label: copy_for("action_label", locale: locale).presence,
|
||||||
starts_at: starts_at&.iso8601,
|
starts_at: starts_at&.iso8601,
|
||||||
ends_at: ends_at&.iso8601
|
ends_at: ends_at&.iso8601
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
def resolved_action_url(platform)
|
def localized_title(locale: I18n.locale)
|
||||||
override = platform_override(platform, :action_url)
|
copy_for("title", locale: locale)
|
||||||
return override if override.present?
|
end
|
||||||
return action_url if action_url.present?
|
|
||||||
|
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"
|
return nil unless kind == "update"
|
||||||
|
|
||||||
case platform.to_s
|
case platform.to_s
|
||||||
@@ -92,21 +129,73 @@ class AppAnnouncement < ApplicationRecord
|
|||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def copy_for(platform, field)
|
def copy_for(field, locale: I18n.locale)
|
||||||
override = platform_override(platform, field)
|
field = field.to_s
|
||||||
override.presence || public_send(field)
|
locale_chain(locale).each do |loc|
|
||||||
|
value = value_for(loc, field)
|
||||||
|
return value if value.present?
|
||||||
|
end
|
||||||
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def platform_override(platform, field)
|
def value_for(locale, key)
|
||||||
prefix = case platform.to_s
|
copy_values_for(locale)[key].presence
|
||||||
when "android" then :android
|
|
||||||
when "ios" then :ios
|
|
||||||
end
|
|
||||||
return if prefix.blank?
|
|
||||||
|
|
||||||
public_send("#{prefix}_#{field}")
|
|
||||||
end
|
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
|
def at_least_one_channel
|
||||||
return if show_on_android? || show_on_ios? || show_on_web_public? || show_on_web_private?
|
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)
|
errors.add(:ends_at, :invalid)
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -37,67 +37,34 @@
|
|||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div>
|
<fieldset class="admin-channels">
|
||||||
<%= f.label :title, t("admin.announcements.form.title") %>
|
<legend><%= t("admin.announcements.form.locales_legend") %></legend>
|
||||||
<%= f.text_field :title, required: true, maxlength: 120 %>
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.locales_hint") %></p>
|
||||||
</div>
|
<div class="admin-locale-tabs" role="tablist">
|
||||||
|
<% language_options.each do |opt| %>
|
||||||
<div>
|
<button type="button"
|
||||||
<%= f.label :body, t("admin.announcements.form.body") %>
|
class="admin-locale-tab<%= " is-active" if opt[:code].to_s == I18n.default_locale.to_s %>"
|
||||||
<%= f.text_area :body, required: true, rows: 5, maxlength: 2000 %>
|
data-locale-tab="<%= opt[:code] %>"
|
||||||
</div>
|
role="tab"
|
||||||
|
aria-selected="<%= opt[:code].to_s == I18n.default_locale.to_s %>">
|
||||||
<div>
|
<span aria-hidden="true"><%= opt[:flag] %></span>
|
||||||
<%= f.label :action_url, t("admin.announcements.form.action_url") %>
|
<%= opt[:native] %>
|
||||||
<%= f.url_field :action_url, placeholder: "https://" %>
|
<% if opt[:code].to_s == I18n.default_locale.to_s %>
|
||||||
</div>
|
<span class="muted">(<%= t("admin.announcements.form.locale_required") %>)</span>
|
||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
|
<% elsif announcement.filled_locale_codes.include?(opt[:code].to_s) %>
|
||||||
|
<span class="muted">✓</span>
|
||||||
<div>
|
<% end %>
|
||||||
<%= f.label :action_label, t("admin.announcements.form.action_label") %>
|
</button>
|
||||||
<%= f.text_field :action_label, maxlength: 40 %>
|
<% end %>
|
||||||
</div>
|
|
||||||
|
|
||||||
<fieldset class="admin-channels admin-platform-copy">
|
|
||||||
<legend><%= t("admin.announcements.form.android_override") %></legend>
|
|
||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.platform_override_hint") %></p>
|
|
||||||
<div>
|
|
||||||
<%= f.label :android_title, t("admin.announcements.form.title") %>
|
|
||||||
<%= f.text_field :android_title, maxlength: 120 %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= f.label :android_body, t("admin.announcements.form.body") %>
|
|
||||||
<%= f.text_area :android_body, rows: 4, maxlength: 2000 %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= f.label :android_action_url, t("admin.announcements.form.action_url") %>
|
|
||||||
<%= f.url_field :android_action_url, placeholder: "https://play.google.com/..." %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= f.label :android_action_label, t("admin.announcements.form.action_label") %>
|
|
||||||
<%= f.text_field :android_action_label, maxlength: 40 %>
|
|
||||||
</div>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="admin-channels admin-platform-copy">
|
|
||||||
<legend><%= t("admin.announcements.form.ios_override") %></legend>
|
|
||||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.platform_override_hint") %></p>
|
|
||||||
<div>
|
|
||||||
<%= f.label :ios_title, t("admin.announcements.form.title") %>
|
|
||||||
<%= f.text_field :ios_title, maxlength: 120 %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= f.label :ios_body, t("admin.announcements.form.body") %>
|
|
||||||
<%= f.text_area :ios_body, rows: 4, maxlength: 2000 %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= f.label :ios_action_url, t("admin.announcements.form.action_url") %>
|
|
||||||
<%= f.url_field :ios_action_url, placeholder: "https://apps.apple.com/..." %>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<%= f.label :ios_action_label, t("admin.announcements.form.action_label") %>
|
|
||||||
<%= f.text_field :ios_action_label, maxlength: 40 %>
|
|
||||||
</div>
|
</div>
|
||||||
|
<% language_options.each do |opt| %>
|
||||||
|
<div class="admin-locale-panel"
|
||||||
|
data-locale-panel="<%= opt[:code] %>"
|
||||||
|
role="tabpanel"
|
||||||
|
<%= "hidden" unless opt[:code].to_s == I18n.default_locale.to_s %>>
|
||||||
|
<%= render "locale_copy", f: f, announcement: announcement, locale: opt[:code] %>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="admin-form-row">
|
<div class="admin-form-row">
|
||||||
@@ -123,3 +90,23 @@
|
|||||||
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
||||||
</div>
|
</div>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var tabs = document.querySelectorAll("[data-locale-tab]");
|
||||||
|
if (!tabs.length) return;
|
||||||
|
tabs.forEach(function (btn) {
|
||||||
|
btn.addEventListener("click", function () {
|
||||||
|
var loc = btn.getAttribute("data-locale-tab");
|
||||||
|
tabs.forEach(function (other) {
|
||||||
|
var on = other === btn;
|
||||||
|
other.classList.toggle("is-active", on);
|
||||||
|
other.setAttribute("aria-selected", on ? "true" : "false");
|
||||||
|
});
|
||||||
|
document.querySelectorAll("[data-locale-panel]").forEach(function (panel) {
|
||||||
|
panel.hidden = panel.getAttribute("data-locale-panel") !== loc;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -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}]" %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% if it %>
|
||||||
|
<%= f.label :title, t("admin.announcements.form.title") %>
|
||||||
|
<%= f.text_field :title, required: true, maxlength: 120, id: "ann_#{locale}_title" %>
|
||||||
|
<% else %>
|
||||||
|
<label for="ann_<%= locale %>_title"><%= t("admin.announcements.form.title") %></label>
|
||||||
|
<%= text_field_tag "#{name_base}[title]", values["title"], maxlength: 120, id: "ann_#{locale}_title" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% 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 %>
|
||||||
|
<label for="ann_<%= locale %>_body"><%= t("admin.announcements.form.body") %></label>
|
||||||
|
<%= text_area_tag "#{name_base}[body]", values["body"], rows: 5, maxlength: 2000, id: "ann_#{locale}_body" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% 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 %>
|
||||||
|
<label for="ann_<%= locale %>_action_url"><%= t("admin.announcements.form.action_url") %></label>
|
||||||
|
<%= url_field_tag "#{name_base}[action_url]", values["action_url"], placeholder: "https://", id: "ann_#{locale}_action_url" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
|
<p class="muted admin-form-hint"><%= t("admin.announcements.form.action_url_hint") %></p>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<% 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 %>
|
||||||
|
<label for="ann_<%= locale %>_action_label"><%= t("admin.announcements.form.action_label") %></label>
|
||||||
|
<%= text_field_tag "#{name_base}[action_label]", values["action_label"], maxlength: 40, id: "ann_#{locale}_action_label" %>
|
||||||
|
<% end %>
|
||||||
|
</div>
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<td>
|
<td>
|
||||||
<strong><%= item.title %></strong>
|
<strong><%= item.title %></strong>
|
||||||
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(item.body, length: 90) %></div>
|
<div class="muted" style="font-size:0.85rem;margin-top:0.25rem"><%= truncate(item.body, length: 90) %></div>
|
||||||
|
<div class="muted" style="font-size:0.8rem;margin-top:0.2rem"><%= item.filled_locale_codes.map(&:upcase).join(" · ") %></div>
|
||||||
</td>
|
</td>
|
||||||
<td class="muted"><%= announcement_channels_label(item) %></td>
|
<td class="muted"><%= announcement_channels_label(item) %></td>
|
||||||
<td class="muted"><%= announcement_window_label(item) %></td>
|
<td class="muted"><%= announcement_window_label(item) %></td>
|
||||||
|
|||||||
@@ -120,11 +120,13 @@
|
|||||||
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
|
<td><span class="badge badge--<%= s.status == 'live' ? 'live' : (s.status == 'paused' ? 'paused' : 'connecting') %>"><%= s.status %></span></td>
|
||||||
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
|
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
|
||||||
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
|
<td class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
|
||||||
<td class="admin-link-compact">
|
<td>
|
||||||
<% admin_session_watch_links(s).each do |link| %>
|
<div class="admin-link-compact">
|
||||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
<% admin_session_watch_links(s).each do |link| %>
|
||||||
<% end %>
|
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||||
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
<% end %>
|
||||||
|
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="admin-actions">
|
<td class="admin-actions">
|
||||||
<%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %>
|
<%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %>
|
||||||
|
|||||||
@@ -17,11 +17,13 @@
|
|||||||
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
|
<td><span class="badge <%= s.status == 'live' ? 'badge--live' : 'badge--paused' %>"><%= s.status %></span></td>
|
||||||
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
|
<td><%= render "admin/sessions/ingest_cell", session: s %></td>
|
||||||
<td><%= s.disconnection_count %></td>
|
<td><%= s.disconnection_count %></td>
|
||||||
<td class="admin-link-compact">
|
<td>
|
||||||
<% admin_session_watch_links(s).each do |link| %>
|
<div class="admin-link-compact">
|
||||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
<% admin_session_watch_links(s).each do |link| %>
|
||||||
<% end %>
|
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||||
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
<% end %>
|
||||||
|
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
|
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<title><%= t("admin.layout.title") %></title>
|
<title><%= t("admin.layout.title") %></title>
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
<link rel="stylesheet" href="/admin.css?v=8">
|
<link rel="stylesheet" href="/admin.css?v=11">
|
||||||
<% if content_for?(:replay_archive_styles) %>
|
<% if content_for?(:replay_archive_styles) %>
|
||||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -5,11 +5,12 @@
|
|||||||
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
||||||
data-announcement-id="<%= item.id %>">
|
data-announcement-id="<%= item.id %>">
|
||||||
<div class="site-announcement__body">
|
<div class="site-announcement__body">
|
||||||
<p class="site-announcement__title"><%= item.title %></p>
|
<p class="site-announcement__title"><%= item.localized_title %></p>
|
||||||
<div class="site-announcement__text"><%= simple_format(item.body) %></div>
|
<div class="site-announcement__text"><%= simple_format(item.localized_body) %></div>
|
||||||
<% if item.action_url.present? %>
|
<% action_url = item.resolved_action_url(nil) %>
|
||||||
|
<% if action_url.present? %>
|
||||||
<p class="site-announcement__action">
|
<p class="site-announcement__action">
|
||||||
<%= 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" %>
|
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|||||||
@@ -421,18 +421,18 @@ de:
|
|||||||
severity: Schwere
|
severity: Schwere
|
||||||
status: Status
|
status: Status
|
||||||
channels: Wo anzeigen
|
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
|
title: Titel
|
||||||
body: Text
|
body: Text
|
||||||
starts_at: Sichtbar ab
|
starts_at: Sichtbar ab
|
||||||
ends_at: Sichtbar bis
|
ends_at: Sichtbar bis
|
||||||
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
|
window_hint: Leer lassen, um sofort und solange veröffentlicht anzuzeigen.
|
||||||
action_url: Link (optional)
|
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
|
action_label: Button-Text
|
||||||
android_override: Nur Android (optional)
|
locales_legend: Text pro Sprache
|
||||||
ios_override: Nur iOS (optional)
|
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.
|
||||||
platform_override_hint: Leer lassen, um gemeinsame Texte und Links zu nutzen. Nur ausfüllen, wenn diese Plattform etwas anderes sehen soll.
|
locale_required: Pflicht
|
||||||
dismissible: Nutzer können den Hinweis schließen
|
dismissible: Nutzer können den Hinweis schließen
|
||||||
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
|
dismissible_hint: Haken entfernen für Wartungshinweise, die sichtbar bleiben sollen.
|
||||||
submit_create: Hinweis speichern
|
submit_create: Hinweis speichern
|
||||||
|
|||||||
@@ -421,18 +421,18 @@ en:
|
|||||||
severity: Severity
|
severity: Severity
|
||||||
status: Status
|
status: Status
|
||||||
channels: Where to show it
|
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
|
title: Title
|
||||||
body: Body
|
body: Body
|
||||||
starts_at: Visible from
|
starts_at: Visible from
|
||||||
ends_at: Visible until
|
ends_at: Visible until
|
||||||
window_hint: Leave empty to show immediately while published.
|
window_hint: Leave empty to show immediately while published.
|
||||||
action_url: Link (optional)
|
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
|
action_label: Button label
|
||||||
android_override: Android only (optional)
|
locales_legend: Copy by language
|
||||||
ios_override: iOS only (optional)
|
locales_hint: Fill in every app language (Italian, English, French, German, Spanish). Italian is required; missing languages fall back to English, then Italian.
|
||||||
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.
|
locale_required: required
|
||||||
dismissible: Users can dismiss the notice
|
dismissible: Users can dismiss the notice
|
||||||
dismissible_hint: Uncheck for maintenance notices that should stay visible.
|
dismissible_hint: Uncheck for maintenance notices that should stay visible.
|
||||||
submit_create: Save notice
|
submit_create: Save notice
|
||||||
|
|||||||
@@ -421,18 +421,18 @@ es:
|
|||||||
severity: Gravedad
|
severity: Gravedad
|
||||||
status: Estado
|
status: Estado
|
||||||
channels: Dónde mostrarlo
|
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
|
title: Título
|
||||||
body: Texto
|
body: Texto
|
||||||
starts_at: Visible desde
|
starts_at: Visible desde
|
||||||
ends_at: Visible hasta
|
ends_at: Visible hasta
|
||||||
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
|
window_hint: Déjalo vacío para mostrarlo de inmediato mientras esté publicado.
|
||||||
action_url: Enlace (opcional)
|
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
|
action_label: Texto del botón
|
||||||
android_override: Solo Android (opcional)
|
locales_legend: Texto por idioma
|
||||||
ios_override: Solo iOS (opcional)
|
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.
|
||||||
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.
|
locale_required: obligatorio
|
||||||
dismissible: El usuario puede cerrar el aviso
|
dismissible: El usuario puede cerrar el aviso
|
||||||
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
|
dismissible_hint: Quita la marca para avisos de mantenimiento que deben permanecer visibles.
|
||||||
submit_create: Guardar aviso
|
submit_create: Guardar aviso
|
||||||
|
|||||||
@@ -421,18 +421,18 @@ fr:
|
|||||||
severity: Gravité
|
severity: Gravité
|
||||||
status: Statut
|
status: Statut
|
||||||
channels: Où l'afficher
|
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
|
title: Titre
|
||||||
body: Texte
|
body: Texte
|
||||||
starts_at: Visible à partir de
|
starts_at: Visible à partir de
|
||||||
ends_at: Visible jusqu'à
|
ends_at: Visible jusqu'à
|
||||||
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
|
window_hint: Laissez vide pour afficher immédiatement tant que l'alerte est publiée.
|
||||||
action_url: Lien (facultatif)
|
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
|
action_label: Libellé du bouton
|
||||||
android_override: Android uniquement (facultatif)
|
locales_legend: Texte par langue
|
||||||
ios_override: iOS uniquement (facultatif)
|
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.
|
||||||
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.
|
locale_required: obligatoire
|
||||||
dismissible: L'utilisateur peut fermer l'alerte
|
dismissible: L'utilisateur peut fermer l'alerte
|
||||||
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
|
dismissible_hint: Décochez pour une maintenance qui doit rester visible.
|
||||||
submit_create: Enregistrer l'alerte
|
submit_create: Enregistrer l'alerte
|
||||||
|
|||||||
@@ -459,18 +459,18 @@ it:
|
|||||||
severity: Gravità
|
severity: Gravità
|
||||||
status: Stato
|
status: Stato
|
||||||
channels: Dove mostrarlo
|
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
|
title: Titolo
|
||||||
body: Testo
|
body: Testo
|
||||||
starts_at: Inizio visibilità
|
starts_at: Inizio visibilità
|
||||||
ends_at: Fine visibilità
|
ends_at: Fine visibilità
|
||||||
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
|
window_hint: Lascia vuoto per mostrare subito e finché resta pubblicato.
|
||||||
action_url: Link (facoltativo)
|
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
|
action_label: Etichetta del pulsante
|
||||||
android_override: Solo Android (facoltativo)
|
locales_legend: Testo per lingua
|
||||||
ios_override: Solo iOS (facoltativo)
|
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.
|
||||||
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.
|
locale_required: obbligatorio
|
||||||
dismissible: L'utente può chiudere l'avviso
|
dismissible: L'utente può chiudere l'avviso
|
||||||
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
|
dismissible_hint: Togli la spunta per avvisi di manutenzione che devono restare visibili.
|
||||||
submit_create: Salva avviso
|
submit_create: Salva avviso
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ de:
|
|||||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
|
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:
|
club:
|
||||||
back_to_club: "← Verein"
|
back_to_club: "← Verein"
|
||||||
sport_label: Hauptsportart
|
sport_label: Hauptsportart
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ en:
|
|||||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Select at least one destination (apps, public website or logged-in area).
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Main sport
|
sport_label: Main sport
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ es:
|
|||||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Elige al menos un destino (apps, sitio público o área privada).
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Deporte principal
|
sport_label: Deporte principal
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ fr:
|
|||||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
|
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:
|
club:
|
||||||
back_to_club: "← Club"
|
back_to_club: "← Club"
|
||||||
sport_label: Sport principal
|
sport_label: Sport principal
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ it:
|
|||||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||||
app_announcement:
|
app_announcement:
|
||||||
no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
|
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:
|
club:
|
||||||
back_to_club: "← Società"
|
back_to_club: "← Società"
|
||||||
sport_label: Sport principale
|
sport_label: Sport principale
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
class AddAnnouncementTranslations < ActiveRecord::Migration[7.2]
|
||||||
|
def change
|
||||||
|
add_column :app_announcements, :translations, :jsonb, null: false, default: {}
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -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
|
||||||
Generated
+2
-9
@@ -10,7 +10,7 @@
|
|||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# 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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
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.datetime "updated_at", null: false
|
||||||
t.boolean "show_on_android", default: true, null: false
|
t.boolean "show_on_android", default: true, null: false
|
||||||
t.boolean "show_on_ios", default: true, null: false
|
t.boolean "show_on_ios", default: true, null: false
|
||||||
t.string "android_title"
|
t.jsonb "translations", default: {}, null: false
|
||||||
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.index ["created_by_admin_id"], name: "index_app_announcements_on_created_by_admin_id"
|
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", "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"
|
t.index ["status"], name: "index_app_announcements_on_status"
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ body.admin-body {
|
|||||||
.admin-table td {
|
.admin-table td {
|
||||||
padding: 0.65rem 0.5rem;
|
padding: 0.65rem 0.5rem;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
border-bottom: 1px solid var(--card-border);
|
border-bottom: 1px solid var(--card-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,10 +236,11 @@ body.admin-body {
|
|||||||
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
|
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
|
||||||
|
|
||||||
.admin-ingest {
|
.admin-ingest {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: row;
|
||||||
align-items: flex-start;
|
flex-wrap: wrap;
|
||||||
gap: 4px;
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
.admin-ingest__slug {
|
.admin-ingest__slug {
|
||||||
font-size: 0.85rem;
|
font-size: 0.85rem;
|
||||||
@@ -321,9 +323,11 @@ body.admin-body {
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Flex sul contenuto, non sulla <td>: altrimenti si rompe vertical-align della riga. */
|
||||||
.admin-link-compact {
|
.admin-link-compact {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
gap: 0.35rem;
|
gap: 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -666,13 +670,34 @@ body.admin-body {
|
|||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-platform-copy {
|
.admin-locale-tabs {
|
||||||
margin-top: 0.35rem;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-platform-copy > div {
|
.admin-locale-tab {
|
||||||
|
background: #2a2a2a;
|
||||||
|
border: 1px solid #444;
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-locale-tab.is-active {
|
||||||
|
border-color: #ff2d2d;
|
||||||
|
background: #3a1515;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-locale-panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.25rem;
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-locale-panel[hidden] {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.admin-form-actions {
|
.admin-form-actions {
|
||||||
|
|||||||
@@ -51,33 +51,22 @@ RSpec.describe AppAnnouncement do
|
|||||||
expect(described_class.for_app).to contain_exactly(android_item, ios_item)
|
expect(described_class.for_app).to contain_exactly(android_item, ios_item)
|
||||||
end
|
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!(
|
item = create_notice!(
|
||||||
title: "Comune",
|
title: "Titolo IT",
|
||||||
body: "Testo comune",
|
body: "Testo IT",
|
||||||
action_url: "https://example.com/comune",
|
translations: {
|
||||||
action_label: "Apri",
|
"en" => { "title" => "EN title", "body" => "EN body" },
|
||||||
android_title: "Aggiorna su Android",
|
"de" => { "title" => "DE title", "body" => "DE body" }
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
android = item.as_api_json(platform: "android")
|
expect(item.as_api_json(locale: :en)[:title]).to eq("EN title")
|
||||||
ios = item.as_api_json(platform: "ios")
|
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(android[:title]).to eq("Aggiorna su Android")
|
expect(item.as_api_json(locale: :it)[:title]).to eq("Titolo IT")
|
||||||
expect(android[:body]).to eq("Apri Play Store")
|
expect(item.filled_locale_codes).to include("it", "en", "de")
|
||||||
expect(android[:action_url]).to include("play.google.com")
|
expect(item.filled_locale_codes).not_to include("fr")
|
||||||
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")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
it "requires at least one channel" do
|
it "requires at least one channel" do
|
||||||
|
|||||||
@@ -39,6 +39,42 @@ RSpec.describe "Admin announcements", type: :request do
|
|||||||
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
|
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
|
||||||
end
|
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
|
it "elimina un avviso" do
|
||||||
item = AppAnnouncement.create!(
|
item = AppAnnouncement.create!(
|
||||||
kind: "info", status: "published", title: "Da togliere", body: "Test"
|
kind: "info", status: "published", title: "Da togliere", body: "Test"
|
||||||
|
|||||||
@@ -31,12 +31,10 @@ RSpec.describe "Api::V1::Announcements", type: :request do
|
|||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "update",
|
kind: "update",
|
||||||
status: "published",
|
status: "published",
|
||||||
title: "Aggiorna",
|
title: "Aggiorna Android",
|
||||||
body: "Versione nuova",
|
body: "Versione nuova",
|
||||||
show_on_android: true,
|
show_on_android: true,
|
||||||
show_on_ios: false,
|
show_on_ios: false
|
||||||
android_title: "Aggiorna Android",
|
|
||||||
android_action_url: "https://play.google.com/store/apps/details?id=com.matchlivetv.match_live_tv"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
get "/api/v1/announcements", params: { platform: "android" }
|
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)
|
ios_body = JSON.parse(response.body)
|
||||||
expect(ios_body.map { |row| row["title"] }).to eq(["Manutenzione stasera"])
|
expect(ios_body.map { |row| row["title"] }).to eq(["Manutenzione stasera"])
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ RSpec.describe "Public site announcements", type: :request do
|
|||||||
expect(response.body).not_to include("Solo soci")
|
expect(response.body).not_to include("Solo soci")
|
||||||
end
|
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
|
it "mostra gli avvisi privati nell'area account dopo il login" do
|
||||||
AppAnnouncement.create!(
|
AppAnnouncement.create!(
|
||||||
kind: "info", status: "published", title: "Solo soci",
|
kind: "info", status: "published", title: "Solo soci",
|
||||||
|
|||||||
Reference in New Issue
Block a user