Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f02782d0f0 | ||
|
|
fb82b74e92 | ||
|
|
a07f231417 | ||
|
|
43d5003de8 | ||
|
|
eadfc29da3 | ||
|
|
50d3e2cf16 | ||
|
|
55e71ca838 | ||
|
|
5b723bf844 |
@@ -0,0 +1,76 @@
|
||||
module Admin
|
||||
class AnnouncementsController < Admin::BaseController
|
||||
before_action :set_announcement, only: %i[edit update destroy]
|
||||
|
||||
def index
|
||||
@announcements = AppAnnouncement.newest
|
||||
end
|
||||
|
||||
def new
|
||||
@announcement = AppAnnouncement.new(
|
||||
kind: "info",
|
||||
severity: "info",
|
||||
status: "draft",
|
||||
show_on_android: true,
|
||||
show_on_ios: true,
|
||||
show_on_web_public: true,
|
||||
show_on_web_private: true,
|
||||
dismissible: true
|
||||
)
|
||||
end
|
||||
|
||||
def create
|
||||
@announcement = AppAnnouncement.new(announcement_params)
|
||||
@announcement.created_by_admin = current_admin_account
|
||||
if @announcement.save
|
||||
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_created")
|
||||
else
|
||||
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit; end
|
||||
|
||||
def update
|
||||
if @announcement.update(announcement_params)
|
||||
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_updated")
|
||||
else
|
||||
flash.now[:alert] = @announcement.errors.full_messages.join(", ")
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@announcement.destroy!
|
||||
redirect_to admin_announcements_path, notice: t("admin.flash.announcement_destroyed")
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_announcement
|
||||
@announcement = AppAnnouncement.find(params[:id])
|
||||
end
|
||||
|
||||
def announcement_params
|
||||
params.require(:app_announcement).permit(
|
||||
: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,
|
||||
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].each do |key|
|
||||
permitted[key] = nil if permitted[key].blank?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -5,7 +5,7 @@ module Admin
|
||||
@host = HostMetrics.new.sample!
|
||||
@active_sessions = StreamSession
|
||||
.where(status: DashboardStats::ACTIVE_STATUSES)
|
||||
.includes(match: :team)
|
||||
.includes(:stream_node, match: :team)
|
||||
.order(started_at: :desc)
|
||||
@teams = Team.includes(:matches).order(:name).limit(8)
|
||||
end
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
module Admin
|
||||
class SessionsController < Admin::BaseController
|
||||
def index
|
||||
@sessions = StreamSession.includes(:match, :user).order(created_at: :desc).limit(50)
|
||||
@sessions = StreamSession.includes(:stream_node, :user, match: :team).order(created_at: :desc).limit(50)
|
||||
end
|
||||
|
||||
def show
|
||||
@session = StreamSession.find(params[:id])
|
||||
@session = StreamSession.includes(:stream_node, match: :team).find(params[:id])
|
||||
@events = @session.stream_events.recent.limit(50)
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
module Api
|
||||
module V1
|
||||
class AnnouncementsController < BaseController
|
||||
skip_before_action :authenticate_request!
|
||||
|
||||
def index
|
||||
platform = params[:platform].to_s.presence
|
||||
announcements = AppAnnouncement.for_platform(platform).newest
|
||||
render json: announcements.map { |item| item.as_api_json(platform: platform) }
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,46 @@
|
||||
module Public
|
||||
class ContactsController < WebBaseController
|
||||
def show
|
||||
@inquiry = empty_inquiry
|
||||
end
|
||||
|
||||
def create
|
||||
result = Contacts::SubmitInquiry.call(params: inquiry_params, ip: request.remote_ip)
|
||||
|
||||
if result.ok
|
||||
redirect_to public_contatti_path, notice: t("flash.contacts.sent")
|
||||
return
|
||||
end
|
||||
|
||||
@inquiry = inquiry_params
|
||||
flash.now[:alert] = t("flash.contacts.#{result.error}")
|
||||
render :show, status: result.error == :throttled ? :too_many_requests : :unprocessable_entity
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inquiry_params
|
||||
{
|
||||
name: params[:name].to_s,
|
||||
email: params[:email].to_s,
|
||||
club_name: params[:club_name].to_s,
|
||||
topic: params[:topic].to_s,
|
||||
message: params[:message].to_s,
|
||||
website: params[:website].to_s,
|
||||
accept_privacy: params[:accept_privacy].to_s
|
||||
}
|
||||
end
|
||||
|
||||
def empty_inquiry
|
||||
{
|
||||
name: "",
|
||||
email: "",
|
||||
club_name: "",
|
||||
topic: "commercial",
|
||||
message: "",
|
||||
website: "",
|
||||
accept_privacy: ""
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -9,6 +9,7 @@ module Public
|
||||
|
||||
protect_from_forgery with: :exception
|
||||
before_action :set_site_locale
|
||||
before_action :load_site_announcements
|
||||
|
||||
private
|
||||
|
||||
@@ -40,5 +41,27 @@ module Public
|
||||
def logged_in?
|
||||
current_user.present?
|
||||
end
|
||||
|
||||
PRIVATE_WEB_CONTROLLERS = %w[
|
||||
accounts clubs teams club_recordings club_billing
|
||||
club_matches matches team_roster_members
|
||||
].freeze
|
||||
|
||||
def load_site_announcements
|
||||
@site_announcements = if skip_site_announcements?
|
||||
AppAnnouncement.none
|
||||
else
|
||||
channel = private_web_area? ? :web_private : :web_public
|
||||
AppAnnouncement.for_channel(channel).newest
|
||||
end
|
||||
end
|
||||
|
||||
def skip_site_announcements?
|
||||
controller_name == "regia"
|
||||
end
|
||||
|
||||
def private_web_area?
|
||||
PRIVATE_WEB_CONTROLLERS.include?(controller_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -8,6 +8,7 @@ module Public
|
||||
{ loc: "#{base}/prezzi", changefreq: "monthly", priority: "0.9" },
|
||||
{ loc: "#{base}/pallavolo-giovanile", changefreq: "monthly", priority: "0.85" },
|
||||
{ loc: "#{base}/faq", changefreq: "monthly", priority: "0.8" },
|
||||
{ loc: "#{base}/contatti", changefreq: "monthly", priority: "0.6" },
|
||||
{ loc: "#{base}/live", changefreq: "hourly", priority: "0.85" },
|
||||
{ loc: "#{base}/squadre", changefreq: "daily", priority: "0.85" },
|
||||
{ loc: "#{base}/privacy", changefreq: "yearly", priority: "0.3" },
|
||||
|
||||
@@ -30,6 +30,19 @@ module AdminHelper
|
||||
links
|
||||
end
|
||||
|
||||
def admin_session_ingest_badge_class(node)
|
||||
case node.role
|
||||
when "home" then "badge--ingest-home"
|
||||
when "lab" then "badge--ingest-lab"
|
||||
when "cloud" then "badge--ingest-cloud"
|
||||
else "badge--paused"
|
||||
end
|
||||
end
|
||||
|
||||
def admin_session_ingest_role_label(node)
|
||||
I18n.t("admin.sessions.ingest.role.#{node.role}", default: node.role.to_s.humanize)
|
||||
end
|
||||
|
||||
def admin_regia_expires_label(iso_time)
|
||||
return if iso_time.blank?
|
||||
|
||||
@@ -37,4 +50,31 @@ module AdminHelper
|
||||
rescue ArgumentError, TypeError
|
||||
nil
|
||||
end
|
||||
|
||||
def announcement_status_badge(item)
|
||||
case item.status
|
||||
when "published" then item.severity == "critical" ? "live" : "ready"
|
||||
when "archived" then "paused"
|
||||
else "connecting"
|
||||
end
|
||||
end
|
||||
|
||||
def announcement_window_label(item)
|
||||
start_label = item.starts_at&.in_time_zone&.strftime("%d/%m %H:%M")
|
||||
end_label = item.ends_at&.in_time_zone&.strftime("%d/%m %H:%M")
|
||||
if start_label && end_label
|
||||
"#{start_label} → #{end_label}"
|
||||
elsif start_label
|
||||
I18n.t("admin.announcements.window.from", time: start_label)
|
||||
elsif end_label
|
||||
I18n.t("admin.announcements.window.until", time: end_label)
|
||||
else
|
||||
I18n.t("admin.announcements.window.always")
|
||||
end
|
||||
end
|
||||
|
||||
def announcement_channels_label(item)
|
||||
labels = item.selected_channels.map { |key| I18n.t("admin.announcements.channels.#{key}") }
|
||||
labels.presence&.join(" · ") || I18n.t("admin.common.dash")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module LegalHelper
|
||||
def legal_last_updated
|
||||
"3 giugno 2026"
|
||||
"20 agosto 2026"
|
||||
end
|
||||
|
||||
def cookie_policy_last_updated
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class ContactMailer < ApplicationMailer
|
||||
def inquiry(name:, email:, club_name:, topic:, message:)
|
||||
@name = name
|
||||
@email = email
|
||||
@club_name = club_name.presence
|
||||
@topic = topic
|
||||
@message = message
|
||||
|
||||
mail(
|
||||
to: recipient_for(topic),
|
||||
reply_to: email,
|
||||
subject: t("mailers.contact.subject.#{topic}", name: name)
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def recipient_for(topic)
|
||||
if topic == "privacy"
|
||||
MatchLiveTv.privacy_controller_email
|
||||
else
|
||||
MatchLiveTv.support_email
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -2,6 +2,7 @@ class AdminAccount < ApplicationRecord
|
||||
include PasswordComplexity
|
||||
|
||||
has_secure_password
|
||||
has_many :app_announcements, foreign_key: :created_by_admin_id, dependent: :nullify, inverse_of: :created_by_admin
|
||||
|
||||
validates :username, presence: true, uniqueness: true
|
||||
end
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
class AppAnnouncement < ApplicationRecord
|
||||
KINDS = %w[info maintenance update].freeze
|
||||
SEVERITIES = %w[info warning critical].freeze
|
||||
STATUSES = %w[draft published archived].freeze
|
||||
CHANNELS = {
|
||||
android: :show_on_android,
|
||||
ios: :show_on_ios,
|
||||
web_public: :show_on_web_public,
|
||||
web_private: :show_on_web_private
|
||||
}.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
|
||||
|
||||
validates :kind, :severity, :status, :title, :body, presence: true
|
||||
validates :kind, inclusion: { in: KINDS }
|
||||
validates :severity, inclusion: { in: SEVERITIES }
|
||||
validates :status, inclusion: { in: STATUSES }
|
||||
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") }
|
||||
scope :active_now, lambda {
|
||||
now = Time.current
|
||||
published
|
||||
.where("starts_at IS NULL OR starts_at <= ?", now)
|
||||
.where("ends_at IS NULL OR ends_at > ?", now)
|
||||
}
|
||||
|
||||
def self.for_channel(channel)
|
||||
column = CHANNELS[channel.to_sym]
|
||||
raise ArgumentError, "unknown announcement channel: #{channel}" if column.blank?
|
||||
|
||||
active_now.where(column => true)
|
||||
end
|
||||
|
||||
def self.for_app
|
||||
active_now.where("show_on_android = TRUE OR show_on_ios = TRUE")
|
||||
end
|
||||
|
||||
def self.for_platform(platform)
|
||||
case platform.to_s
|
||||
when "android" then for_channel(:android)
|
||||
when "ios" then for_channel(:ios)
|
||||
else for_app
|
||||
end
|
||||
end
|
||||
|
||||
def published?
|
||||
status == "published"
|
||||
end
|
||||
|
||||
def selected_channels
|
||||
CHANNELS.each_with_object([]) do |(key, column), keys|
|
||||
keys << key if public_send(column)
|
||||
end
|
||||
end
|
||||
|
||||
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("title", locale: locale),
|
||||
body: copy_for("body", locale: locale),
|
||||
dismissible: dismissible,
|
||||
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 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
|
||||
when "android" then MatchLiveTv.play_store_url
|
||||
when "ios" then MatchLiveTv.app_store_url
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
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 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?
|
||||
|
||||
errors.add(:base, :no_channel)
|
||||
end
|
||||
|
||||
def ends_at_after_starts_at
|
||||
return if starts_at.blank? || ends_at.blank?
|
||||
return if ends_at > starts_at
|
||||
|
||||
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
|
||||
@@ -0,0 +1,102 @@
|
||||
module Contacts
|
||||
class SubmitInquiry
|
||||
TOPICS = %w[commercial support privacy].freeze
|
||||
LIMIT = 5
|
||||
WINDOW = 1.hour
|
||||
NAME_MAX = 120
|
||||
CLUB_MAX = 160
|
||||
MESSAGE_MIN = 10
|
||||
MESSAGE_MAX = 4_000
|
||||
|
||||
Result = Struct.new(:ok, :error, keyword_init: true)
|
||||
|
||||
def self.call(params:, ip:)
|
||||
new(params: params, ip: ip).call
|
||||
end
|
||||
|
||||
def initialize(params:, ip:)
|
||||
@params = params
|
||||
@ip = ip.to_s.presence || "unknown"
|
||||
end
|
||||
|
||||
def call
|
||||
if honeypot_filled?
|
||||
record_attempt
|
||||
return Result.new(ok: true, error: nil)
|
||||
end
|
||||
|
||||
return Result.new(ok: false, error: :throttled) if throttled?
|
||||
|
||||
error = validate
|
||||
return Result.new(ok: false, error: error) if error
|
||||
|
||||
record_attempt
|
||||
deliver
|
||||
Result.new(ok: true, error: nil)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def honeypot_filled?
|
||||
@params[:website].to_s.strip.present?
|
||||
end
|
||||
|
||||
def throttled?
|
||||
current_count >= LIMIT
|
||||
end
|
||||
|
||||
def validate
|
||||
return :privacy_required unless @params[:accept_privacy].to_s == "1"
|
||||
return :invalid if name.blank? || name.length > NAME_MAX
|
||||
return :invalid_email if email.blank? || email !~ URI::MailTo::EMAIL_REGEXP
|
||||
return :invalid if club_name.length > CLUB_MAX
|
||||
return :invalid unless TOPICS.include?(topic)
|
||||
return :invalid_message if message.length < MESSAGE_MIN || message.length > MESSAGE_MAX
|
||||
|
||||
nil
|
||||
end
|
||||
|
||||
def deliver
|
||||
mail = ContactMailer.inquiry(
|
||||
name: name,
|
||||
email: email,
|
||||
club_name: club_name,
|
||||
topic: topic,
|
||||
message: message
|
||||
)
|
||||
MatchLiveTv.deliver_mail(mail)
|
||||
end
|
||||
|
||||
def record_attempt
|
||||
Rails.cache.write(cache_key, current_count + 1, expires_in: WINDOW, raw: true)
|
||||
end
|
||||
|
||||
def current_count
|
||||
Rails.cache.read(cache_key, raw: true).to_i
|
||||
end
|
||||
|
||||
def cache_key
|
||||
"contacts:inquiry:#{@ip}"
|
||||
end
|
||||
|
||||
def name
|
||||
@params[:name].to_s.strip
|
||||
end
|
||||
|
||||
def email
|
||||
@params[:email].to_s.strip.downcase
|
||||
end
|
||||
|
||||
def club_name
|
||||
@params[:club_name].to_s.strip
|
||||
end
|
||||
|
||||
def topic
|
||||
@params[:topic].to_s.strip
|
||||
end
|
||||
|
||||
def message
|
||||
@params[:message].to_s.strip
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,112 @@
|
||||
<%= form_with model: announcement, url: (announcement.persisted? ? admin_announcement_path(announcement) : admin_announcements_path),
|
||||
method: (announcement.persisted? ? :patch : :post), local: true, class: "admin-form" do |f| %>
|
||||
<div class="admin-form-row">
|
||||
<div>
|
||||
<%= f.label :kind, t("admin.announcements.form.kind") %>
|
||||
<%= f.select :kind, AppAnnouncement::KINDS.map { |k| [t("admin.announcements.kind.#{k}"), k] } %>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :severity, t("admin.announcements.form.severity") %>
|
||||
<%= f.select :severity, AppAnnouncement::SEVERITIES.map { |k| [t("admin.announcements.severity.#{k}"), k] } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= f.label :status, t("admin.announcements.form.status") %>
|
||||
<%= f.select :status, AppAnnouncement::STATUSES.map { |k| [t("admin.announcements.status.#{k}"), k] } %>
|
||||
</div>
|
||||
|
||||
<fieldset class="admin-channels">
|
||||
<legend><%= t("admin.announcements.form.channels") %></legend>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_android %>
|
||||
<span><%= t("admin.announcements.channels.android") %></span>
|
||||
</label>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_ios %>
|
||||
<span><%= t("admin.announcements.channels.ios") %></span>
|
||||
</label>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_web_public %>
|
||||
<span><%= t("admin.announcements.channels.web_public") %></span>
|
||||
</label>
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :show_on_web_private %>
|
||||
<span><%= t("admin.announcements.channels.web_private") %></span>
|
||||
</label>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.channels_hint") %></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="admin-channels">
|
||||
<legend><%= t("admin.announcements.form.locales_legend") %></legend>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.locales_hint") %></p>
|
||||
<div class="admin-locale-tabs" role="tablist">
|
||||
<% language_options.each do |opt| %>
|
||||
<button type="button"
|
||||
class="admin-locale-tab<%= " is-active" if opt[:code].to_s == I18n.default_locale.to_s %>"
|
||||
data-locale-tab="<%= opt[:code] %>"
|
||||
role="tab"
|
||||
aria-selected="<%= opt[:code].to_s == I18n.default_locale.to_s %>">
|
||||
<span aria-hidden="true"><%= opt[:flag] %></span>
|
||||
<%= opt[:native] %>
|
||||
<% if opt[:code].to_s == I18n.default_locale.to_s %>
|
||||
<span class="muted">(<%= t("admin.announcements.form.locale_required") %>)</span>
|
||||
<% elsif announcement.filled_locale_codes.include?(opt[:code].to_s) %>
|
||||
<span class="muted">✓</span>
|
||||
<% end %>
|
||||
</button>
|
||||
<% end %>
|
||||
</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>
|
||||
|
||||
<div class="admin-form-row">
|
||||
<div>
|
||||
<%= f.label :starts_at, t("admin.announcements.form.starts_at") %>
|
||||
<%= f.datetime_field :starts_at, value: announcement.starts_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||
</div>
|
||||
<div>
|
||||
<%= f.label :ends_at, t("admin.announcements.form.ends_at") %>
|
||||
<%= f.datetime_field :ends_at, value: announcement.ends_at&.in_time_zone&.strftime("%Y-%m-%dT%H:%M") %>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.window_hint") %></p>
|
||||
|
||||
<label class="admin-checkbox">
|
||||
<%= f.check_box :dismissible %>
|
||||
<span><%= t("admin.announcements.form.dismissible") %></span>
|
||||
</label>
|
||||
<p class="muted admin-form-hint"><%= t("admin.announcements.form.dismissible_hint") %></p>
|
||||
|
||||
<div class="admin-form-actions">
|
||||
<%= f.submit (announcement.persisted? ? t("admin.announcements.form.submit_update") : t("admin.announcements.form.submit_create")), class: "admin-btn admin-btn--primary" %>
|
||||
<%= link_to t("admin.announcements.form.cancel"), admin_announcements_path, class: "admin-btn admin-btn--outline" %>
|
||||
</div>
|
||||
<% 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>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.announcements.edit.title") %></h2>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<%= render "form", announcement: @announcement %>
|
||||
</div>
|
||||
@@ -0,0 +1,57 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.announcements.index.title") %></h2>
|
||||
<p class="muted admin-page-sub"><%= t("admin.announcements.index.lead") %></p>
|
||||
</div>
|
||||
|
||||
<div class="panel" style="margin-bottom:1.5rem">
|
||||
<div class="admin-toolbar">
|
||||
<%= link_to t("admin.announcements.index.new"), new_admin_announcement_path, class: "admin-btn admin-btn--primary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<h2><%= t("admin.announcements.index.table_title") %></h2>
|
||||
<% if @announcements.any? %>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><%= t("admin.announcements.table.status") %></th>
|
||||
<th><%= t("admin.announcements.table.kind") %></th>
|
||||
<th><%= t("admin.announcements.table.title") %></th>
|
||||
<th><%= t("admin.announcements.table.channels") %></th>
|
||||
<th><%= t("admin.announcements.table.window") %></th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @announcements.each do |item| %>
|
||||
<tr>
|
||||
<td>
|
||||
<span class="badge badge--<%= announcement_status_badge(item) %>"><%= t("admin.announcements.status.#{item.status}") %></span>
|
||||
</td>
|
||||
<td class="muted"><%= t("admin.announcements.kind.#{item.kind}") %></td>
|
||||
<td>
|
||||
<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.8rem;margin-top:0.2rem"><%= item.filled_locale_codes.map(&:upcase).join(" · ") %></div>
|
||||
</td>
|
||||
<td class="muted"><%= announcement_channels_label(item) %></td>
|
||||
<td class="muted"><%= announcement_window_label(item) %></td>
|
||||
<td class="admin-actions">
|
||||
<%= link_to t("admin.announcements.actions.edit"), edit_admin_announcement_path(item), class: "admin-btn admin-btn--sm" %>
|
||||
<%= button_to t("admin.announcements.actions.delete"), admin_announcement_path(item), method: :delete,
|
||||
class: "admin-btn admin-btn--sm admin-btn--danger",
|
||||
form: { data: { confirm: t("admin.announcements.actions.delete_confirm") } } %>
|
||||
</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="empty"><%= t("admin.announcements.index.none") %></p>
|
||||
<% end %>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
<% content_for :body_class, "admin-body" %>
|
||||
|
||||
<div class="admin-page-head">
|
||||
<h2 class="admin-page-title"><%= t("admin.announcements.new.title") %></h2>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<%= render "form", announcement: @announcement %>
|
||||
</div>
|
||||
@@ -107,6 +107,7 @@
|
||||
<tr>
|
||||
<th><%= t("admin.dashboard.sessions.table.match") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.status") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.ingest") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.start") %></th>
|
||||
<th><%= t("admin.dashboard.sessions.table.link") %></th>
|
||||
<th></th>
|
||||
@@ -117,12 +118,15 @@
|
||||
<tr>
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></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 class="muted"><%= s.started_at&.strftime("%d/%m %H:%M") || t("admin.common.dash") %></td>
|
||||
<td class="admin-link-compact">
|
||||
<% admin_session_watch_links(s).each do |link| %>
|
||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||
<% end %>
|
||||
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||
<td>
|
||||
<div class="admin-link-compact">
|
||||
<% admin_session_watch_links(s).each do |link| %>
|
||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||
<% end %>
|
||||
<%= link_to t("admin.dashboard.sessions.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||
</div>
|
||||
</td>
|
||||
<td class="admin-actions">
|
||||
<%= link_to t("admin.dashboard.sessions.detail"), admin_session_path(s) %>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<% node = session.stream_node %>
|
||||
<% if node %>
|
||||
<div class="admin-ingest">
|
||||
<code class="admin-ingest__slug"><%= node.slug %></code>
|
||||
<span class="badge <%= admin_session_ingest_badge_class(node) %>"><%= admin_session_ingest_role_label(node) %></span>
|
||||
</div>
|
||||
<% else %>
|
||||
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
||||
<% end %>
|
||||
@@ -4,6 +4,7 @@
|
||||
<tr>
|
||||
<th><%= t("admin.sessions.index.table.match") %></th>
|
||||
<th><%= t("admin.sessions.index.table.status") %></th>
|
||||
<th><%= t("admin.sessions.index.table.ingest") %></th>
|
||||
<th><%= t("admin.sessions.index.table.disconnects") %></th>
|
||||
<th><%= t("admin.sessions.index.table.link") %></th>
|
||||
<th></th>
|
||||
@@ -14,12 +15,15 @@
|
||||
<tr>
|
||||
<td><%= s.match.team.name %> vs <%= s.match.opponent_name %></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><%= s.disconnection_count %></td>
|
||||
<td class="admin-link-compact">
|
||||
<% admin_session_watch_links(s).each do |link| %>
|
||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||
<% end %>
|
||||
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||
<td>
|
||||
<div class="admin-link-compact">
|
||||
<% admin_session_watch_links(s).each do |link| %>
|
||||
<%= link_to link[:label], link[:url], target: "_blank", rel: "noopener", class: "admin-link-chip" %>
|
||||
<% end %>
|
||||
<%= link_to t("admin.sessions.index.regia"), admin_session_path(s), class: "admin-link-chip admin-link-chip--regia" %>
|
||||
</div>
|
||||
</td>
|
||||
<td><%= link_to t("admin.sessions.index.detail"), admin_session_path(s) %></td>
|
||||
</tr>
|
||||
|
||||
@@ -16,6 +16,16 @@
|
||||
<% if @session.youtube_broadcast_id %>
|
||||
<p><%= t("admin.sessions.show.youtube_studio") %>: <a href="https://studio.youtube.com/video/<%= @session.youtube_broadcast_id %>/livestreaming" target="_blank" rel="noopener"><%= t("admin.sessions.show.broadcast") %></a></p>
|
||||
<% end %>
|
||||
<p>
|
||||
<%= t("admin.sessions.show.ingest_node") %>
|
||||
<% if @session.stream_node %>
|
||||
<code><%= @session.stream_node.slug %></code>
|
||||
<span class="badge <%= admin_session_ingest_badge_class(@session.stream_node) %>"><%= admin_session_ingest_role_label(@session.stream_node) %></span>
|
||||
<span class="muted">(<%= @session.stream_node.provider %>)</span>
|
||||
<% else %>
|
||||
<span class="muted"><%= t("admin.sessions.ingest.none") %></span>
|
||||
<% end %>
|
||||
</p>
|
||||
<p><%= t("admin.sessions.show.rtmp_ingest") %> <code><%= @session.rtmp_ingest_url %></code></p>
|
||||
|
||||
<h3><%= t("admin.sessions.show.events_title") %></h3>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<p><strong><%= t("mailers.contact.heading") %></strong></p>
|
||||
|
||||
<p>
|
||||
<%= t("mailers.contact.name") %> <%= @name %><br>
|
||||
<%= t("mailers.contact.email") %> <%= @email %><br>
|
||||
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %><br>
|
||||
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
|
||||
</p>
|
||||
|
||||
<p><%= t("mailers.contact.message") %></p>
|
||||
<p style="white-space:pre-wrap"><%= @message %></p>
|
||||
|
||||
<p style="color:#666;font-size:12px"><%= t("mailers.contact.footer") %></p>
|
||||
@@ -0,0 +1,11 @@
|
||||
<%= t("mailers.contact.heading") %>
|
||||
|
||||
<%= t("mailers.contact.name") %> <%= @name %>
|
||||
<%= t("mailers.contact.email") %> <%= @email %>
|
||||
<%= t("mailers.contact.club") %> <%= @club_name.presence || t("mailers.contact.club_none") %>
|
||||
<%= t("mailers.contact.topic") %> <%= t("mailers.contact.topic_labels.#{@topic}") %>
|
||||
|
||||
<%= t("mailers.contact.message") %>
|
||||
<%= @message %>
|
||||
|
||||
<%= t("mailers.contact.footer") %>
|
||||
@@ -4,7 +4,7 @@
|
||||
<title><%= t("admin.layout.title") %></title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="stylesheet" href="/admin.css?v=4">
|
||||
<link rel="stylesheet" href="/admin.css?v=11">
|
||||
<% if content_for?(:replay_archive_styles) %>
|
||||
<link rel="stylesheet" href="/marketing.css?v=42">
|
||||
<% end %>
|
||||
@@ -25,6 +25,7 @@
|
||||
<%= link_to admin_ops_path, class: ("active" if controller_name == "ops") do %>
|
||||
<%= t("admin.layout.nav.ops") %><% if ops_critical.positive? %> <span class="admin-nav-badge"><%= ops_critical %></span><% end %>
|
||||
<% end %>
|
||||
<%= link_to t("admin.layout.nav.announcements"), admin_announcements_path, class: ("active" if controller_name == "announcements") %>
|
||||
<%= link_to t("admin.layout.nav.clubs"), admin_clubs_path, class: ("active" if controller_name.in?(%w[clubs teams club_recordings])) %>
|
||||
<%= link_to t("admin.layout.nav.billing"), admin_billing_path, class: ("active" if controller_name.in?(%w[billing billing_invoices])) %>
|
||||
<%= link_to t("admin.layout.nav.youtube"), admin_youtube_platform_path, class: ("active" if controller_name == "youtube") %>
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
<%= render "shared/meta_tags" %>
|
||||
<%= yield :head %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=64">
|
||||
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
<%= render(@app_store_review_chrome ? "shared/marketing_nav_app_store" : "shared/marketing_nav") %>
|
||||
<%= render "shared/site_announcements" %>
|
||||
<main>
|
||||
<% if flash[:notice] %><div class="wrap"><div class="flash notice"><%= flash[:notice] %></div></div><% end %>
|
||||
<% if flash[:alert] %><div class="wrap"><div class="flash alert"><%= flash[:alert] %></div></div><% end %>
|
||||
|
||||
@@ -6,13 +6,14 @@
|
||||
<title><%= content_for?(:title) ? yield(:title) : "Match Live TV" %></title>
|
||||
<%= render "shared/meta_tags" %>
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer">
|
||||
<link rel="stylesheet" href="/marketing.css?v=64">
|
||||
<link rel="stylesheet" href="/marketing.css?v=68">
|
||||
<link rel="stylesheet" href="/live.css?v=26">
|
||||
<%= yield :head %>
|
||||
</head>
|
||||
<body data-confirm-i18n='<%= raw confirm_dialog_i18n_json %>'<% if MatchLiveTv.google_analytics_configured? %> data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>>
|
||||
<%= render "shared/cookie_banner" %>
|
||||
<%= render "shared/marketing_nav" %>
|
||||
<%= render "shared/site_announcements" %>
|
||||
<main class="live-main">
|
||||
<%= yield %>
|
||||
</main>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% content_for :title, t("club.edit.title", name: @club.name) %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<div class="wrap wrap--portal" style="padding-top:20px">
|
||||
<h1><%= t("club.edit.heading") %></h1>
|
||||
<div class="card">
|
||||
<%= form_with url: public_club_path(@club), method: :patch, multipart: true do %>
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
<% content_for :title, t("pages.contacts.meta_title") %>
|
||||
<% content_for :meta_description, t("pages.contacts.meta_description") %>
|
||||
<% content_for :canonical_url, seo_absolute_url(public_contatti_path) %>
|
||||
|
||||
<article class="contacts-page">
|
||||
<header class="features-hero contacts-hero">
|
||||
<div class="wrap">
|
||||
<p class="features-hero__eyebrow"><%= t("pages.contacts.eyebrow") %></p>
|
||||
<h1><%= t("pages.contacts.title") %></h1>
|
||||
<p class="features-hero__lead"><%= t("pages.contacts.lead") %></p>
|
||||
<p class="contacts-hero__meta"><%= t("pages.contacts.response_time") %></p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="section wrap" aria-label="<%= t("pages.contacts.title") %>">
|
||||
<div class="contacts-channels">
|
||||
<article class="feature-card">
|
||||
<span class="feature-card__icon" aria-hidden="true"><i class="fa-solid fa-briefcase"></i></span>
|
||||
<h2><%= t("pages.contacts.commercial_title") %></h2>
|
||||
<p><%= t("pages.contacts.commercial_body") %></p>
|
||||
<p class="contacts-channels__email">
|
||||
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<span class="feature-card__icon feature-card__icon--blue" aria-hidden="true"><i class="fa-solid fa-life-ring"></i></span>
|
||||
<h2><%= t("pages.contacts.support_title") %></h2>
|
||||
<p>
|
||||
<%= raw t(
|
||||
"pages.contacts.support_body_html",
|
||||
faq_link: link_to(t("pages.contacts.faq_link"), public_faq_path),
|
||||
support_link: link_to(t("pages.contacts.support_link"), public_support_path)
|
||||
) %>
|
||||
</p>
|
||||
<p class="contacts-channels__email">
|
||||
<%= mail_to MatchLiveTv.support_email, MatchLiveTv.support_email %>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
<article class="feature-card">
|
||||
<span class="feature-card__icon feature-card__icon--gold" aria-hidden="true"><i class="fa-solid fa-shield-halved"></i></span>
|
||||
<h2><%= t("pages.contacts.privacy_title") %></h2>
|
||||
<p><%= t("pages.contacts.privacy_body") %></p>
|
||||
<p class="contacts-channels__email">
|
||||
<%= mail_to MatchLiveTv.privacy_controller_email, MatchLiveTv.privacy_controller_email %>
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap" aria-labelledby="contacts-company-title">
|
||||
<div class="features-panel contacts-company">
|
||||
<h2 id="contacts-company-title"><%= t("pages.contacts.company_title") %></h2>
|
||||
<p class="features-panel__lead"><%= t("pages.contacts.company_lead") %></p>
|
||||
<ul class="contacts-company__details">
|
||||
<li><strong><%= MatchLiveTv.privacy_controller_name %></strong></li>
|
||||
<li><%= t("pages.contacts.label_address") %>: <%= MatchLiveTv.privacy_controller_address %></li>
|
||||
<% if MatchLiveTv.privacy_controller_vat.present? %>
|
||||
<li><%= t("pages.contacts.label_vat") %>: <%= MatchLiveTv.privacy_controller_vat %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap" aria-labelledby="contacts-form-title">
|
||||
<div class="contacts-form-panel">
|
||||
<h2 id="contacts-form-title"><%= t("pages.contacts.form_title") %></h2>
|
||||
<p class="contacts-form-panel__lead"><%= t("pages.contacts.form_lead") %></p>
|
||||
|
||||
<%= form_with url: public_contatti_path, method: :post, local: true, class: "contacts-form" do %>
|
||||
<div class="contacts-hp" aria-hidden="true">
|
||||
<label for="website"><%= t("pages.contacts.honeypot_label") %></label>
|
||||
<%= text_field_tag :website, @inquiry[:website], tabindex: -1, autocomplete: "off" %>
|
||||
</div>
|
||||
|
||||
<%= label_tag :name, t("pages.contacts.name_label") %>
|
||||
<%= text_field_tag :name, @inquiry[:name], required: true, maxlength: 120, autocomplete: "name" %>
|
||||
|
||||
<%= label_tag :email, t("pages.contacts.email_label") %>
|
||||
<%= email_field_tag :email, @inquiry[:email], required: true, autocomplete: "email" %>
|
||||
|
||||
<%= label_tag :club_name, t("pages.contacts.club_label") %>
|
||||
<%= text_field_tag :club_name, @inquiry[:club_name], maxlength: 160, autocomplete: "organization" %>
|
||||
|
||||
<%= label_tag :topic, t("pages.contacts.topic_label") %>
|
||||
<%= select_tag :topic, options_for_select(
|
||||
[
|
||||
[t("pages.contacts.topic_commercial"), "commercial"],
|
||||
[t("pages.contacts.topic_support"), "support"],
|
||||
[t("pages.contacts.topic_privacy"), "privacy"]
|
||||
],
|
||||
@inquiry[:topic]
|
||||
), required: true %>
|
||||
|
||||
<%= label_tag :message, t("pages.contacts.message_label") %>
|
||||
<%= text_area_tag :message, @inquiry[:message], required: true, rows: 6, maxlength: 4000 %>
|
||||
|
||||
<label class="legal-accept">
|
||||
<%= check_box_tag :accept_privacy, "1", @inquiry[:accept_privacy] == "1", required: true %>
|
||||
<span class="legal-accept-text">
|
||||
<%= raw t(
|
||||
"pages.contacts.privacy_accept_html",
|
||||
privacy_link: link_to(t("pages.contacts.privacy_link"), public_privacy_path, target: "_blank", rel: "noopener")
|
||||
) %>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<%= submit_tag t("pages.contacts.submit"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section wrap features-cta" aria-labelledby="contacts-cta-title">
|
||||
<div class="features-cta__box">
|
||||
<h2 id="contacts-cta-title"><%= t("pages.contacts.cta_title") %></h2>
|
||||
<p><%= t("pages.contacts.cta_body") %></p>
|
||||
<div class="features-cta__actions">
|
||||
<%= link_to t("pages.contacts.cta_faq"), public_faq_path, class: "btn btn-outline" %>
|
||||
<%= link_to t("pages.contacts.cta_signup"), public_signup_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</article>
|
||||
@@ -83,6 +83,6 @@
|
||||
<div class="card" style="margin-top:32px;text-align:center">
|
||||
<h3 style="margin-top:0"><%= t("pages.pricing.different_title") %></h3>
|
||||
<p style="color:#aaa;margin-bottom:16px"><%= t("pages.pricing.different_body") %></p>
|
||||
<%= mail_to "info@matchlivetv.it", t("pages.pricing.contact_button"), class: "btn btn-primary" %>
|
||||
<%= link_to t("pages.pricing.contact_button"), public_contatti_path, class: "btn btn-primary" %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<li><%= raw t("legal.privacy.s3_item4_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s3_item5_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s3_item6_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s3_item7_html") %></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
@@ -107,6 +108,10 @@
|
||||
<td><%= t("legal.privacy.s5_row6_purpose") %></td>
|
||||
<td><%= t("legal.privacy.s5_row6_basis") %></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><%= t("legal.privacy.s5_row7_purpose") %></td>
|
||||
<td><%= t("legal.privacy.s5_row7_basis") %></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -144,6 +149,7 @@
|
||||
<li><%= raw t("legal.privacy.s8_item2_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s8_item3_html") %></li>
|
||||
<li><%= raw t("legal.privacy.s8_item4_html", hours: MatchLiveTv.password_reset_expiry_hours) %></li>
|
||||
<li><%= raw t("legal.privacy.s8_item5_html") %></li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<% content_for :title, t("team.edit.title", name: @team.name) %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<div class="wrap" style="padding-top:20px;max-width:560px">
|
||||
<div class="wrap wrap--portal" style="padding-top:20px">
|
||||
<h1><%= @team.name %></h1>
|
||||
<p style="color:#888"><%= t("team.edit.club_label") %> <%= link_to @club.name, public_club_path(@club) %></p>
|
||||
<div class="card">
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<% record = local_assigns[:record] %>
|
||||
<% show_inherit_hint = local_assigns[:show_inherit_hint] %>
|
||||
<% can_upload = local_assigns.fetch(:can_upload, false) %>
|
||||
<% cover_src = record.effective_cover_url.presence || Streams::CoverResolver::DEFAULT_COVER_URL %>
|
||||
<% default_cover = Streams::CoverResolver::DEFAULT_COVER_URL %>
|
||||
<% cover_src = record.effective_cover_url.presence || default_cover %>
|
||||
<% remove_id = "remove_cover_#{record.model_name.param_key}" %>
|
||||
|
||||
<fieldset class="branding-fields cover-fields">
|
||||
<legend><%= t("club.branding.cover_legend") %></legend>
|
||||
@@ -22,16 +24,25 @@
|
||||
</div>
|
||||
|
||||
<% if can_upload %>
|
||||
<%= file_field_tag "branding[cover_image]",
|
||||
accept: "image/png,image/jpeg,image/webp",
|
||||
data: { branding_cover_file: true },
|
||||
class: "branding-file-input" %>
|
||||
<% if record.cover_image_attached? %>
|
||||
<label class="checkbox-label cover-remove-label">
|
||||
<%= check_box_tag "remove_cover", "1", false, id: "remove_cover_#{record.model_name.param_key}" %>
|
||||
<%= t("club.branding.cover_remove_label") %>
|
||||
</label>
|
||||
<% end %>
|
||||
<div class="cover-actions">
|
||||
<%= file_field_tag "branding[cover_image]",
|
||||
accept: "image/png,image/jpeg,image/webp",
|
||||
data: { branding_cover_file: true },
|
||||
class: "branding-file-input" %>
|
||||
<% if record.cover_image_attached? %>
|
||||
<%= hidden_field_tag "remove_cover", "0", id: remove_id, data: { branding_cover_remove: true } %>
|
||||
<button type="button"
|
||||
class="btn btn-secondary cover-reset-btn"
|
||||
data-branding-cover-reset
|
||||
data-default-cover="<%= default_cover %>"
|
||||
data-remove-input="#<%= remove_id %>">
|
||||
<%= t("club.branding.cover_reset_button") %>
|
||||
</button>
|
||||
<p class="branding-hint cover-reset-hint" data-branding-cover-reset-hint hidden>
|
||||
<%= t("club.branding.cover_reset_pending_hint") %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<p class="muted"><%= t("club.branding.cover_requires_full") %></p>
|
||||
<%= link_to t("club.branding.cover_see_plans"), public_prezzi_path, class: "btn btn-secondary btn-sm" %>
|
||||
@@ -43,13 +54,29 @@
|
||||
(function () {
|
||||
var input = document.querySelector("[data-branding-cover-file]");
|
||||
var img = document.querySelector("[data-branding-cover-img]");
|
||||
if (!input || !img) return;
|
||||
input.addEventListener("change", function () {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) { img.src = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
var resetBtn = document.querySelector("[data-branding-cover-reset]");
|
||||
var removeInput = document.querySelector("[data-branding-cover-remove]");
|
||||
var resetHint = document.querySelector("[data-branding-cover-reset-hint]");
|
||||
if (input && img) {
|
||||
input.addEventListener("change", function () {
|
||||
var file = input.files && input.files[0];
|
||||
if (!file) return;
|
||||
if (removeInput) removeInput.value = "0";
|
||||
if (resetHint) resetHint.hidden = true;
|
||||
if (resetBtn) resetBtn.disabled = false;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function (e) { img.src = e.target.result; };
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
if (resetBtn && img && removeInput) {
|
||||
resetBtn.addEventListener("click", function () {
|
||||
removeInput.value = "1";
|
||||
img.src = resetBtn.getAttribute("data-default-cover");
|
||||
if (input) input.value = "";
|
||||
if (resetHint) resetHint.hidden = false;
|
||||
resetBtn.disabled = true;
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<%= render "shared/store_badges", variant: "footer" %>
|
||||
</div>
|
||||
<div>
|
||||
<%= link_to t("common.contacts"), public_contatti_path %> ·
|
||||
<%= link_to t("common.support"), public_support_path %> ·
|
||||
<%= link_to t("common.pricing"), public_prezzi_path %> ·
|
||||
<%= link_to t("footer.live"), public_live_index_path %> ·
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<% announcements = @site_announcements %>
|
||||
<% if announcements.present? %>
|
||||
<div class="site-announcements" role="region" aria-label="<%= t("announcement.region") %>">
|
||||
<% announcements.each do |item| %>
|
||||
<article class="site-announcement site-announcement--<%= item.kind %> site-announcement--<%= item.severity %>"
|
||||
data-announcement-id="<%= item.id %>">
|
||||
<div class="site-announcement__body">
|
||||
<p class="site-announcement__title"><%= item.localized_title %></p>
|
||||
<div class="site-announcement__text"><%= simple_format(item.localized_body) %></div>
|
||||
<% action_url = item.resolved_action_url(nil) %>
|
||||
<% if action_url.present? %>
|
||||
<p class="site-announcement__action">
|
||||
<%= link_to (item.localized_action_label.presence || t("announcement.open")), action_url,
|
||||
class: "site-announcement__link", target: "_blank", rel: "noopener noreferrer" %>
|
||||
</p>
|
||||
<% end %>
|
||||
</div>
|
||||
<% if item.dismissible? %>
|
||||
<button type="button" class="site-announcement__dismiss" data-announcement-dismiss aria-label="<%= t("announcement.dismiss") %>">
|
||||
×
|
||||
</button>
|
||||
<% end %>
|
||||
</article>
|
||||
<% end %>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var key = "mltv.webDismissedAnnouncements";
|
||||
var dismissed = [];
|
||||
try { dismissed = JSON.parse(localStorage.getItem(key) || "[]"); } catch (e) {}
|
||||
document.querySelectorAll(".site-announcement[data-announcement-id]").forEach(function (el) {
|
||||
if (dismissed.indexOf(el.getAttribute("data-announcement-id")) !== -1) el.remove();
|
||||
});
|
||||
document.querySelectorAll("[data-announcement-dismiss]").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
var box = btn.closest("[data-announcement-id]");
|
||||
if (!box) return;
|
||||
var id = box.getAttribute("data-announcement-id");
|
||||
if (dismissed.indexOf(id) === -1) dismissed.push(id);
|
||||
localStorage.setItem(key, JSON.stringify(dismissed));
|
||||
box.remove();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
<% end %>
|
||||
@@ -5,6 +5,7 @@ de:
|
||||
nav:
|
||||
dashboard: Dashboard
|
||||
ops: Ops
|
||||
announcements: Hinweise
|
||||
clubs: Vereine & Teams
|
||||
billing: Abrechnung
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ de:
|
||||
invoice_updated: "Rechnung %{number} aktualisiert."
|
||||
invoice_uploaded: "Rechnung hochgeladen und an %{email} gesendet."
|
||||
youtube_not_configured: Konfiguriere YOUTUBE_CLIENT_ID und YOUTUBE_CLIENT_SECRET in .env
|
||||
announcement_created: Hinweis gespeichert
|
||||
announcement_updated: Hinweis aktualisiert
|
||||
announcement_destroyed: Hinweis gelöscht
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Ja"
|
||||
@@ -106,6 +110,7 @@ de:
|
||||
table:
|
||||
match: Spiel
|
||||
status: Status
|
||||
ingest: Ingest
|
||||
start: Start
|
||||
link: Link
|
||||
none: Derzeit keine aktive Sitzung.
|
||||
@@ -249,6 +254,7 @@ de:
|
||||
table:
|
||||
match: Spiel
|
||||
status: Status
|
||||
ingest: Ingest
|
||||
disconnects: Verbindungsabbrüche
|
||||
link: Link
|
||||
detail: Details
|
||||
@@ -261,6 +267,7 @@ de:
|
||||
match_label: "Spiel: %{team} gegen %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Übertragung
|
||||
ingest_node: "Ingest-Knoten:"
|
||||
rtmp_ingest: "RTMP-Ingest:"
|
||||
events_title: Ereignisse
|
||||
table:
|
||||
@@ -277,6 +284,13 @@ de:
|
||||
active_exists: Es existiert bereits ein aktiver Regie-Link (die URL kann nicht abgerufen werden). Erzeuge einen neuen, wenn du ihn erneut teilen musst.
|
||||
none_active: Kein aktiver Regie-Link. Erzeuge einen Link zum Teilen mit der Person, die den Spielstand verwaltet.
|
||||
generate_button: Regie-Link erzeugen
|
||||
ingest:
|
||||
none: "—"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Zu berechnende Zahlungen
|
||||
@@ -360,3 +374,68 @@ de:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replays — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Hinweise
|
||||
lead: Meldungen für die Apps, die öffentliche Website und den Login-Bereich — Wartung, Updates oder Mitteilungen.
|
||||
new: Neuer Hinweis
|
||||
table_title: Hinweise
|
||||
none: Noch keine Hinweise. Erstelle einen, um Apps und Website zu informieren.
|
||||
new:
|
||||
title: Neuer Hinweis
|
||||
edit:
|
||||
title: Hinweis bearbeiten
|
||||
table:
|
||||
status: Status
|
||||
kind: Typ
|
||||
title: Nachricht
|
||||
channels: Ziele
|
||||
window: Zeitraum
|
||||
actions:
|
||||
edit: Bearbeiten
|
||||
delete: Löschen
|
||||
delete_confirm: Diesen Hinweis löschen? Er erscheint nicht mehr an den gewählten Stellen.
|
||||
kind:
|
||||
info: Information
|
||||
maintenance: Wartung
|
||||
update: App-Update
|
||||
severity:
|
||||
info: Info
|
||||
warning: Warnung
|
||||
critical: Kritisch
|
||||
status:
|
||||
draft: Entwurf
|
||||
published: Veröffentlicht
|
||||
archived: Archiviert
|
||||
channels:
|
||||
android: Android-App
|
||||
ios: iOS-App
|
||||
web_public: Öffentliche Website
|
||||
web_private: Login-Bereich
|
||||
window:
|
||||
always: Immer (solange veröffentlicht)
|
||||
from: "Ab %{time}"
|
||||
until: "Bis %{time}"
|
||||
form:
|
||||
kind: Typ
|
||||
severity: Schwere
|
||||
status: Status
|
||||
channels: Wo anzeigen
|
||||
channels_hint: "Eine oder mehrere Ziele wählen. 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 (Play Store auf Android, App Store auf iOS).
|
||||
action_label: Button-Text
|
||||
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
|
||||
submit_update: Hinweis aktualisieren
|
||||
cancel: Abbrechen
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ en:
|
||||
nav:
|
||||
dashboard: Dashboard
|
||||
ops: Ops
|
||||
announcements: Notices
|
||||
clubs: Clubs & teams
|
||||
billing: Billing
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ en:
|
||||
invoice_updated: "Invoice %{number} updated."
|
||||
invoice_uploaded: "Invoice uploaded and sent to %{email}."
|
||||
youtube_not_configured: Configure YOUTUBE_CLIENT_ID and YOUTUBE_CLIENT_SECRET in .env
|
||||
announcement_created: Notice saved
|
||||
announcement_updated: Notice updated
|
||||
announcement_destroyed: Notice deleted
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Yes"
|
||||
@@ -106,6 +110,7 @@ en:
|
||||
table:
|
||||
match: Match
|
||||
status: Status
|
||||
ingest: Ingest
|
||||
start: Start
|
||||
link: Link
|
||||
none: No active session right now.
|
||||
@@ -249,6 +254,7 @@ en:
|
||||
table:
|
||||
match: Match
|
||||
status: Status
|
||||
ingest: Ingest
|
||||
disconnects: Disconnects
|
||||
link: Link
|
||||
detail: Details
|
||||
@@ -261,6 +267,7 @@ en:
|
||||
match_label: "Match: %{team} vs %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Broadcast
|
||||
ingest_node: "Ingest node:"
|
||||
rtmp_ingest: "RTMP ingest:"
|
||||
events_title: Events
|
||||
table:
|
||||
@@ -277,6 +284,13 @@ en:
|
||||
active_exists: An active control link already exists (the URL cannot be retrieved). Generate a new one if you need to share it again.
|
||||
none_active: No active control link. Generate a link to share with whoever manages the score.
|
||||
generate_button: Generate control link
|
||||
ingest:
|
||||
none: "—"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Payments to invoice
|
||||
@@ -360,3 +374,68 @@ en:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replays — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Notices
|
||||
lead: Messages for the apps, the public website and the logged-in area — maintenance, updates or announcements.
|
||||
new: New notice
|
||||
table_title: Notices
|
||||
none: No notices yet. Create one to notify the apps and the website.
|
||||
new:
|
||||
title: New notice
|
||||
edit:
|
||||
title: Edit notice
|
||||
table:
|
||||
status: Status
|
||||
kind: Type
|
||||
title: Message
|
||||
channels: Destinations
|
||||
window: Window
|
||||
actions:
|
||||
edit: Edit
|
||||
delete: Delete
|
||||
delete_confirm: Delete this notice? It will no longer appear in the selected destinations.
|
||||
kind:
|
||||
info: Information
|
||||
maintenance: Maintenance
|
||||
update: App update
|
||||
severity:
|
||||
info: Info
|
||||
warning: Warning
|
||||
critical: Critical
|
||||
status:
|
||||
draft: Draft
|
||||
published: Published
|
||||
archived: Archived
|
||||
channels:
|
||||
android: Android app
|
||||
ios: iOS app
|
||||
web_public: Public website
|
||||
web_private: Logged-in area
|
||||
window:
|
||||
always: Always (while published)
|
||||
from: "From %{time}"
|
||||
until: "Until %{time}"
|
||||
form:
|
||||
kind: Type
|
||||
severity: Severity
|
||||
status: Status
|
||||
channels: Where to show it
|
||||
channels_hint: "Select one or more destinations. 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 (Play Store on Android, App Store on iOS).
|
||||
action_label: Button label
|
||||
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
|
||||
submit_update: Update notice
|
||||
cancel: Cancel
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ es:
|
||||
nav:
|
||||
dashboard: Panel
|
||||
ops: Ops
|
||||
announcements: Avisos
|
||||
clubs: Clubes y equipos
|
||||
billing: Facturación
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ es:
|
||||
invoice_updated: "Factura %{number} actualizada."
|
||||
invoice_uploaded: "Factura subida y enviada a %{email}."
|
||||
youtube_not_configured: Configura YOUTUBE_CLIENT_ID y YOUTUBE_CLIENT_SECRET en .env
|
||||
announcement_created: Aviso guardado
|
||||
announcement_updated: Aviso actualizado
|
||||
announcement_destroyed: Aviso eliminado
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Sí"
|
||||
@@ -106,6 +110,7 @@ es:
|
||||
table:
|
||||
match: Partido
|
||||
status: Estado
|
||||
ingest: Ingest
|
||||
start: Inicio
|
||||
link: Enlace
|
||||
none: No hay ninguna sesión activa en este momento.
|
||||
@@ -249,6 +254,7 @@ es:
|
||||
table:
|
||||
match: Partido
|
||||
status: Estado
|
||||
ingest: Ingest
|
||||
disconnects: Desconexiones
|
||||
link: Enlace
|
||||
detail: Detalle
|
||||
@@ -261,6 +267,7 @@ es:
|
||||
match_label: "Partido: %{team} vs %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Emisión
|
||||
ingest_node: "Nodo ingest:"
|
||||
rtmp_ingest: "Ingesta RTMP:"
|
||||
events_title: Eventos
|
||||
table:
|
||||
@@ -277,6 +284,13 @@ es:
|
||||
active_exists: Ya existe un enlace de regie activo (la URL no se puede recuperar). Genera uno nuevo si necesitas compartirlo de nuevo.
|
||||
none_active: No hay ningún enlace de regie activo. Genera un enlace para compartir con quien gestione el marcador.
|
||||
generate_button: Generar enlace de regie
|
||||
ingest:
|
||||
none: "—"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Pagos por facturar
|
||||
@@ -360,3 +374,68 @@ es:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Repeticiones — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Avisos
|
||||
lead: Mensajes hacia las apps, el sitio público y el área privada — mantenimiento, actualizaciones o comunicados.
|
||||
new: Nuevo aviso
|
||||
table_title: Avisos
|
||||
none: Todavía no hay avisos. Crea uno para notificar a las apps y al sitio.
|
||||
new:
|
||||
title: Nuevo aviso
|
||||
edit:
|
||||
title: Editar aviso
|
||||
table:
|
||||
status: Estado
|
||||
kind: Tipo
|
||||
title: Mensaje
|
||||
channels: Destinos
|
||||
window: Ventana
|
||||
actions:
|
||||
edit: Editar
|
||||
delete: Eliminar
|
||||
delete_confirm: ¿Eliminar este aviso? Ya no se verá en los destinos elegidos.
|
||||
kind:
|
||||
info: Información
|
||||
maintenance: Mantenimiento
|
||||
update: Actualización
|
||||
severity:
|
||||
info: Info
|
||||
warning: Atención
|
||||
critical: Crítico
|
||||
status:
|
||||
draft: Borrador
|
||||
published: Publicado
|
||||
archived: Archivado
|
||||
channels:
|
||||
android: App Android
|
||||
ios: App iOS
|
||||
web_public: Sitio público
|
||||
web_private: Área privada
|
||||
window:
|
||||
always: Siempre (mientras esté publicado)
|
||||
from: "Desde %{time}"
|
||||
until: "Hasta %{time}"
|
||||
form:
|
||||
kind: Tipo
|
||||
severity: Gravedad
|
||||
status: Estado
|
||||
channels: Dónde mostrarlo
|
||||
channels_hint: "Elige uno o más destinos. 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 (Play Store en Android, App Store en iOS).
|
||||
action_label: Texto del botón
|
||||
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
|
||||
submit_update: Actualizar aviso
|
||||
cancel: Cancelar
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ fr:
|
||||
nav:
|
||||
dashboard: Tableau de bord
|
||||
ops: Ops
|
||||
announcements: Alertes
|
||||
clubs: Clubs et équipes
|
||||
billing: Facturation
|
||||
youtube: YouTube
|
||||
@@ -45,6 +46,9 @@ fr:
|
||||
invoice_updated: "Facture %{number} mise à jour."
|
||||
invoice_uploaded: "Facture chargée et envoyée à %{email}."
|
||||
youtube_not_configured: Configurez YOUTUBE_CLIENT_ID et YOUTUBE_CLIENT_SECRET dans .env
|
||||
announcement_created: Alerte enregistrée
|
||||
announcement_updated: Alerte mise à jour
|
||||
announcement_destroyed: Alerte supprimée
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Oui"
|
||||
@@ -106,6 +110,7 @@ fr:
|
||||
table:
|
||||
match: Match
|
||||
status: Statut
|
||||
ingest: Ingest
|
||||
start: Début
|
||||
link: Lien
|
||||
none: Aucune session active pour le moment.
|
||||
@@ -249,6 +254,7 @@ fr:
|
||||
table:
|
||||
match: Match
|
||||
status: Statut
|
||||
ingest: Ingest
|
||||
disconnects: Déconnexions
|
||||
link: Lien
|
||||
detail: Détail
|
||||
@@ -261,6 +267,7 @@ fr:
|
||||
match_label: "Match : %{team} contre %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Diffusion
|
||||
ingest_node: "Nœud ingest :"
|
||||
rtmp_ingest: "Ingestion RTMP :"
|
||||
events_title: Événements
|
||||
table:
|
||||
@@ -277,6 +284,13 @@ fr:
|
||||
active_exists: Un lien de régie actif existe déjà (l'URL n'est pas récupérable). Générez-en un nouveau si vous devez le repartager.
|
||||
none_active: Aucun lien de régie actif. Générez un lien à partager avec la personne qui gère le score.
|
||||
generate_button: Générer le lien de régie
|
||||
ingest:
|
||||
none: "—"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Paiements à facturer
|
||||
@@ -360,3 +374,68 @@ fr:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replays — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Alertes
|
||||
lead: Messages vers les apps, le site public et l'espace connecté — maintenance, mises à jour ou communications.
|
||||
new: Nouvelle alerte
|
||||
table_title: Alertes
|
||||
none: Aucune alerte. Créez-en une pour notifier les applications et le site.
|
||||
new:
|
||||
title: Nouvelle alerte
|
||||
edit:
|
||||
title: Modifier l'alerte
|
||||
table:
|
||||
status: Statut
|
||||
kind: Type
|
||||
title: Message
|
||||
channels: Destinations
|
||||
window: Fenêtre
|
||||
actions:
|
||||
edit: Modifier
|
||||
delete: Supprimer
|
||||
delete_confirm: Supprimer cette alerte ? Elle n'apparaîtra plus aux destinations choisies.
|
||||
kind:
|
||||
info: Information
|
||||
maintenance: Maintenance
|
||||
update: Mise à jour
|
||||
severity:
|
||||
info: Info
|
||||
warning: Attention
|
||||
critical: Critique
|
||||
status:
|
||||
draft: Brouillon
|
||||
published: Publié
|
||||
archived: Archivé
|
||||
channels:
|
||||
android: App Android
|
||||
ios: App iOS
|
||||
web_public: Site public
|
||||
web_private: Espace connecté
|
||||
window:
|
||||
always: Toujours (tant que publié)
|
||||
from: "À partir de %{time}"
|
||||
until: "Jusqu'à %{time}"
|
||||
form:
|
||||
kind: Type
|
||||
severity: Gravité
|
||||
status: Statut
|
||||
channels: Où l'afficher
|
||||
channels_hint: "Choisissez une ou plusieurs destinations. 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 (Play Store sur Android, App Store sur iOS).
|
||||
action_label: Libellé du bouton
|
||||
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
|
||||
submit_update: Mettre à jour l'alerte
|
||||
cancel: Annuler
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ it:
|
||||
nav:
|
||||
dashboard: Dashboard
|
||||
ops: Ops
|
||||
announcements: Avvisi
|
||||
clubs: Società e squadre
|
||||
billing: Fatturazione
|
||||
youtube: YouTube
|
||||
@@ -49,6 +50,9 @@ it:
|
||||
transfer_confirmed: "Bonifico confermato: piano %{plan} attivo per %{club}."
|
||||
transfer_cancelled: Bonifico in attesa annullato.
|
||||
youtube_not_configured: Configura YOUTUBE_CLIENT_ID e YOUTUBE_CLIENT_SECRET in .env
|
||||
announcement_created: Avviso salvato
|
||||
announcement_updated: Avviso aggiornato
|
||||
announcement_destroyed: Avviso eliminato
|
||||
common:
|
||||
free_plan: Free
|
||||
yes: "Sì"
|
||||
@@ -110,6 +114,7 @@ it:
|
||||
table:
|
||||
match: Partita
|
||||
status: Stato
|
||||
ingest: Ingest
|
||||
start: Inizio
|
||||
link: Link
|
||||
none: Nessuna sessione attiva in questo momento.
|
||||
@@ -270,6 +275,7 @@ it:
|
||||
table:
|
||||
match: Match
|
||||
status: Stato
|
||||
ingest: Ingest
|
||||
disconnects: Disconnessioni
|
||||
link: Link
|
||||
detail: Dettaglio
|
||||
@@ -282,6 +288,7 @@ it:
|
||||
match_label: "Match: %{team} vs %{opponent}"
|
||||
youtube_studio: YouTube Studio
|
||||
broadcast: Broadcast
|
||||
ingest_node: "Nodo ingest:"
|
||||
rtmp_ingest: "RTMP ingest:"
|
||||
events_title: Eventi
|
||||
table:
|
||||
@@ -298,6 +305,13 @@ it:
|
||||
active_exists: Esiste già un link regia attivo (l’URL non è recuperabile). Generane uno nuovo se serve condividerlo di nuovo.
|
||||
none_active: Nessun link regia attivo. Genera un link da condividere con chi gestisce il punteggio.
|
||||
generate_button: Genera link regia
|
||||
ingest:
|
||||
none: "—"
|
||||
role:
|
||||
home: Home-lab
|
||||
lab: Lab
|
||||
cloud: Hetzner
|
||||
|
||||
billing:
|
||||
index:
|
||||
title: Pagamenti da fatturare
|
||||
@@ -398,3 +412,68 @@ it:
|
||||
club_recordings:
|
||||
index:
|
||||
title: "Replay — %{club}"
|
||||
announcements:
|
||||
index:
|
||||
title: Avvisi
|
||||
lead: Messaggi verso le app, il sito pubblico e l'area riservata — manutenzione, aggiornamenti o comunicazioni.
|
||||
new: Nuovo avviso
|
||||
table_title: Avvisi
|
||||
none: Nessun avviso. Crea il primo per notificare app e sito.
|
||||
new:
|
||||
title: Nuovo avviso
|
||||
edit:
|
||||
title: Modifica avviso
|
||||
table:
|
||||
status: Stato
|
||||
kind: Tipo
|
||||
title: Messaggio
|
||||
channels: Destinazioni
|
||||
window: Finestra
|
||||
actions:
|
||||
edit: Modifica
|
||||
delete: Elimina
|
||||
delete_confirm: Eliminare questo avviso? Non sarà più visibile nelle destinazioni scelte.
|
||||
kind:
|
||||
info: Informazione
|
||||
maintenance: Manutenzione
|
||||
update: Aggiornamento app
|
||||
severity:
|
||||
info: Info
|
||||
warning: Attenzione
|
||||
critical: Critico
|
||||
status:
|
||||
draft: Bozza
|
||||
published: Pubblicato
|
||||
archived: Archiviato
|
||||
channels:
|
||||
android: App Android
|
||||
ios: App iOS
|
||||
web_public: Sito pubblico
|
||||
web_private: Area riservata
|
||||
window:
|
||||
always: Sempre (finché pubblicato)
|
||||
from: "Da %{time}"
|
||||
until: "Fino a %{time}"
|
||||
form:
|
||||
kind: Tipo
|
||||
severity: Gravità
|
||||
status: Stato
|
||||
channels: Dove mostrarlo
|
||||
channels_hint: "Puoi selezionare una o più destinazioni. 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 (Play Store su Android, App Store su iOS).
|
||||
action_label: Etichetta del pulsante
|
||||
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
|
||||
submit_update: Aggiorna avviso
|
||||
cancel: Annulla
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ de:
|
||||
too_short: "ist zu kurz (mindestens %{count} Zeichen)"
|
||||
too_long: "ist zu lang (höchstens %{count} Zeichen)"
|
||||
complexity: "muss mindestens 3 aus Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen enthalten"
|
||||
app_announcement:
|
||||
no_channel: Wähle mindestens ein Ziel (Apps, öffentliche Website oder Login-Bereich).
|
||||
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
|
||||
@@ -700,6 +704,13 @@ de:
|
||||
password_same_as_current: Das neue Passwort muss sich vom aktuellen unterscheiden
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||
contacts:
|
||||
sent: Nachricht gesendet. Wir antworten an die angegebene E-Mail-Adresse.
|
||||
throttled: Du hast zu viele Anfragen gesendet. Bitte versuche es in einer Stunde erneut.
|
||||
invalid: Prüfe die Pflichtfelder und versuche es erneut.
|
||||
invalid_email: Gib eine gültige E-Mail-Adresse ein.
|
||||
invalid_message: Die Nachricht ist zu kurz oder zu lang.
|
||||
privacy_required: Zum Senden des Formulars musst du die Datenschutzerklärung akzeptieren.
|
||||
accounts:
|
||||
name_required: Der Name ist erforderlich
|
||||
profile_updated: Profil aktualisiert.
|
||||
|
||||
@@ -16,6 +16,10 @@ en:
|
||||
too_short: "is too short (minimum is %{count} characters)"
|
||||
too_long: "is too long (maximum is %{count} characters)"
|
||||
complexity: "must include at least 3 of: lowercase letters, uppercase letters, numbers and symbols"
|
||||
app_announcement:
|
||||
no_channel: Select at least one destination (apps, public website or logged-in area).
|
||||
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
|
||||
@@ -39,6 +43,8 @@ en:
|
||||
cover_file_label: Cover image
|
||||
cover_preview_alt: Live cover preview
|
||||
cover_remove_label: Remove custom cover
|
||||
cover_reset_button: Restore standard cover
|
||||
cover_reset_pending_hint: The standard cover will be restored when you save.
|
||||
cover_requires_full: Custom sponsor covers are available on the Premium Full plan.
|
||||
cover_see_plans: See plans
|
||||
dashboard:
|
||||
@@ -703,6 +709,13 @@ en:
|
||||
password_same_as_current: New password must be different from the current password
|
||||
password_mismatch: Passwords don't match
|
||||
password_updated: Password updated. You can now log in.
|
||||
contacts:
|
||||
sent: Message sent. We will reply to the email you provided.
|
||||
throttled: You have sent too many requests. Please try again in an hour.
|
||||
invalid: Check the required fields and try again.
|
||||
invalid_email: Enter a valid email address.
|
||||
invalid_message: The message is too short or too long.
|
||||
privacy_required: To send the form you must accept the privacy notice.
|
||||
accounts:
|
||||
name_required: Name is required
|
||||
profile_updated: Profile updated.
|
||||
|
||||
@@ -21,6 +21,10 @@ es:
|
||||
too_short: "es demasiado corta (mínimo %{count} caracteres)"
|
||||
too_long: "es demasiado larga (máximo %{count} caracteres)"
|
||||
complexity: "debe incluir al menos 3 entre: minúsculas, mayúsculas, números y símbolos"
|
||||
app_announcement:
|
||||
no_channel: Elige al menos un destino (apps, sitio público o área privada).
|
||||
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
|
||||
@@ -700,6 +704,13 @@ es:
|
||||
password_same_as_current: La nueva contraseña debe ser distinta de la actual
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||
contacts:
|
||||
sent: Mensaje enviado. Te responderemos al email indicado.
|
||||
throttled: Has enviado demasiadas solicitudes. Inténtalo de nuevo dentro de una hora.
|
||||
invalid: Revisa los campos obligatorios e inténtalo de nuevo.
|
||||
invalid_email: Introduce una dirección de email válida.
|
||||
invalid_message: El mensaje es demasiado corto o demasiado largo.
|
||||
privacy_required: Para enviar el formulario debes aceptar la política de privacidad.
|
||||
accounts:
|
||||
name_required: El nombre es obligatorio
|
||||
profile_updated: Perfil actualizado.
|
||||
|
||||
@@ -21,6 +21,10 @@ fr:
|
||||
too_short: "est trop court (minimum %{count} caractères)"
|
||||
too_long: "est trop long (maximum %{count} caractères)"
|
||||
complexity: "doit inclure au moins 3 parmi : minuscules, majuscules, chiffres et symboles"
|
||||
app_announcement:
|
||||
no_channel: Choisissez au moins une destination (apps, site public ou espace connecté).
|
||||
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
|
||||
@@ -700,6 +704,13 @@ fr:
|
||||
password_same_as_current: "Le nouveau mot de passe doit être différent de l'actuel"
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||
contacts:
|
||||
sent: Message envoyé. Nous te répondrons à l'email indiqué.
|
||||
throttled: Tu as envoyé trop de demandes. Réessaie dans une heure.
|
||||
invalid: Vérifie les champs obligatoires et réessaie.
|
||||
invalid_email: Saisis une adresse email valide.
|
||||
invalid_message: Le message est trop court ou trop long.
|
||||
privacy_required: Pour envoyer le formulaire tu dois accepter la notice de confidentialité.
|
||||
accounts:
|
||||
name_required: Le nom est obligatoire
|
||||
profile_updated: Profil mis à jour.
|
||||
|
||||
@@ -16,6 +16,10 @@ it:
|
||||
too_short: "è troppo corta (minimo %{count} caratteri)"
|
||||
too_long: "è troppo lunga (massimo %{count} caratteri)"
|
||||
complexity: "deve includere almeno 3 tra: lettere minuscole, maiuscole, numeri e simboli"
|
||||
app_announcement:
|
||||
no_channel: Seleziona almeno una destinazione (app, sito pubblico o area riservata).
|
||||
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
|
||||
@@ -39,6 +43,8 @@ it:
|
||||
cover_file_label: Immagine copertina
|
||||
cover_preview_alt: Anteprima copertina diretta
|
||||
cover_remove_label: Rimuovi copertina personalizzata
|
||||
cover_reset_button: Ripristina copertina standard
|
||||
cover_reset_pending_hint: Verrà ripristinata la copertina standard al salvataggio.
|
||||
cover_requires_full: La copertina sponsor personalizzata è disponibile con il piano Premium Full.
|
||||
cover_see_plans: Vedi piani
|
||||
dashboard:
|
||||
@@ -729,6 +735,13 @@ it:
|
||||
password_too_weak: "La password deve includere almeno 3 tra: minuscole, maiuscole, numeri e simboli"
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata. Ora puoi accedere.
|
||||
contacts:
|
||||
sent: Messaggio inviato. Ti rispondiamo all'email indicata.
|
||||
throttled: Hai inviato troppe richieste. Riprova tra un'ora.
|
||||
invalid: Controlla i campi obbligatori e riprova.
|
||||
invalid_email: Inserisci un indirizzo email valido.
|
||||
invalid_message: Il messaggio è troppo corto o troppo lungo.
|
||||
privacy_required: Per inviare il modulo devi accettare l'informativa privacy.
|
||||
accounts:
|
||||
name_required: Il nome è obbligatorio
|
||||
profile_updated: Profilo aggiornato.
|
||||
|
||||
@@ -28,6 +28,7 @@ de:
|
||||
s3_item4_html: "<strong>Audiovisuelle Inhalte:</strong> Video- und Audiostreams der live übertragenen Spiele und, bei Premium-Paketen, Aufzeichnungen (Replays), die für einen begrenzten Zeitraum gespeichert werden."
|
||||
s3_item5_html: "<strong>Zahlungsdaten:</strong> Für kostenpflichtige Pakete verarbeitet Stripe die für die Zahlung erforderlichen Daten (z. B. Zahlungsmethode, Rechnungsangaben). Der Verantwortliche speichert keine vollständigen Kartennummern."
|
||||
s3_item6_html: "<strong>Nutzungsdaten:</strong> Serverprotokolle, IP-Adresse, User-Agent, erforderlich für Sicherheit und Funktionsfähigkeit der Website."
|
||||
s3_item7_html: "<strong>Daten des Kontaktformulars:</strong> Name, E-Mail, optionaler Verein, Anliegen und Nachricht von der Kontaktseite."
|
||||
s4_title: 4. Verarbeitung von Bildern und Daten minderjähriger Athleten
|
||||
s4_p1_html: "Match Live TV richtet sich auch an Jugendwettbewerbe und -kategorien (z. B. U14, U16, U18). Bei Livestreams können identifizierbare <strong>minderjährige Athleten</strong> erfasst werden (Bild, Stimme, Trikotnummer, Spieldaten)."
|
||||
s4_lead1: Wer für die Verarbeitung von Bildern Minderjähriger verantwortlich ist
|
||||
@@ -56,6 +57,8 @@ de:
|
||||
s5_row5_basis: Vertragserfüllung
|
||||
s5_row6_purpose: Direktmarketing (nur wenn vorgesehen und ggf. mit Einwilligung)
|
||||
s5_row6_basis: Einwilligung oder berechtigtes Interesse, je nach Fall
|
||||
s5_row7_purpose: Bearbeitung von Anfragen über das Kontaktformular
|
||||
s5_row7_basis: Vorvertragliche Maßnahmen; berechtigtes Interesse an der Beantwortung von Anfragen
|
||||
s6_title: 6. Empfänger und Auftragsverarbeiter
|
||||
s6_intro: "Die Daten können an Dienstleister weitergegeben werden, die als Auftragsverarbeiter tätig sind, unter anderem:"
|
||||
s6_item1: Anbieter von Hosting- und Cloud-/Serverinfrastruktur;
|
||||
@@ -72,6 +75,7 @@ de:
|
||||
s8_item2_html: "<strong>Spielaufzeichnungen (Replays):</strong> je nach Paket (z. B. 30 oder 90 Tage), danach automatische Löschung oder Löschung auf Anfrage."
|
||||
s8_item3_html: "<strong>Technische Protokolle:</strong> begrenzter Zeitraum (z. B. 30–90 Tage), sofern keine Sicherheitspflichten entgegenstehen."
|
||||
s8_item4_html: "<strong>Token zum Zurücksetzen des Passworts:</strong> maximale Gültigkeit %{hours} Stunden."
|
||||
s8_item5_html: "<strong>Anfragen über das Kontaktformular:</strong> nur so lange gespeichert, wie zur Bearbeitung der Anfrage erforderlich, danach aus dem betrieblichen Postfach gelöscht."
|
||||
s9_title: 9. Rechte der betroffenen Personen
|
||||
s9_intro: "Als betroffene Person haben Sie das Recht:"
|
||||
s9_item1: auf Zugang zu Ihren Daten und Erhalt einer Kopie;
|
||||
|
||||
@@ -28,6 +28,7 @@ en:
|
||||
s3_item4_html: "<strong>Audiovisual content:</strong> video and audio streams of matches broadcast live and, with Premium plans, recordings (replays) kept for a limited period."
|
||||
s3_item5_html: "<strong>Payment data:</strong> for paid plans, Stripe processes the data required for payment (e.g. payment method, billing details). The controller does not store full card numbers."
|
||||
s3_item6_html: "<strong>Browsing data:</strong> server logs, IP address, user agent, needed for the security and operation of the site."
|
||||
s3_item7_html: "<strong>Contact form data:</strong> name, email, optional club, topic and message submitted from the Contact page."
|
||||
s4_title: 4. Processing of images and data of minor athletes
|
||||
s4_p1_html: "Match Live TV is also intended for youth competitions and categories (e.g. Under 14, Under 16, Under 18). Live streams may capture identifiable <strong>minor athletes</strong> (image, voice, jersey number, game data)."
|
||||
s4_lead1: Who is responsible for processing images of minors
|
||||
@@ -56,6 +57,8 @@ en:
|
||||
s5_row5_basis: Performance of a contract
|
||||
s5_row6_purpose: Direct marketing (only where applicable and with consent where required)
|
||||
s5_row6_basis: Consent or legitimate interest, depending on the case
|
||||
s5_row7_purpose: Handling requests sent via the contact form
|
||||
s5_row7_basis: Pre-contractual measures; legitimate interest in answering enquiries
|
||||
s6_title: 6. Recipients and data processors
|
||||
s6_intro: "Data may be shared with providers acting as data processors, including:"
|
||||
s6_item1: hosting and cloud/server infrastructure provider;
|
||||
@@ -72,6 +75,7 @@ en:
|
||||
s8_item2_html: "<strong>Match recordings (replays):</strong> according to the plan (e.g. 30 or 90 days), then automatic deletion or deletion on request."
|
||||
s8_item3_html: "<strong>Technical logs:</strong> a limited period (e.g. 30–90 days), unless security obligations require otherwise."
|
||||
s8_item4_html: "<strong>Password reset token:</strong> maximum validity %{hours} hours."
|
||||
s8_item5_html: "<strong>Contact form enquiries:</strong> kept only for as long as needed to handle the request, then deleted from the operational mailbox."
|
||||
s9_title: 9. Data subject rights
|
||||
s9_intro: "As a data subject you have the right to:"
|
||||
s9_item1: access your data and obtain a copy of it;
|
||||
|
||||
@@ -28,6 +28,7 @@ es:
|
||||
s3_item4_html: "<strong>Contenidos audiovisuales:</strong> flujos de vídeo y audio de los partidos transmitidos en directo y, con los planes Premium, grabaciones (repeticiones) conservadas durante un periodo limitado."
|
||||
s3_item5_html: "<strong>Datos de pago:</strong> para los planes de pago, Stripe trata los datos necesarios para el pago (por ejemplo, método de pago, datos de facturación). El responsable no conserva los números completos de la tarjeta."
|
||||
s3_item6_html: "<strong>Datos de navegación:</strong> registros del servidor, dirección IP, user agent, necesarios para la seguridad y el funcionamiento del sitio."
|
||||
s3_item7_html: "<strong>Datos del formulario de contacto:</strong> nombre, email, club opcional, motivo y mensaje enviados desde la página de Contacto."
|
||||
s4_title: 4. Tratamiento de imágenes y datos de deportistas menores de edad
|
||||
s4_p1_html: "Match Live TV también está destinado a competiciones y categorías juveniles (por ejemplo, sub-14, sub-16, sub-18). Los directos pueden captar a <strong>deportistas menores de edad</strong> identificables (imagen, voz, número de camiseta, datos de juego)."
|
||||
s4_lead1: Quién es responsable del tratamiento de las imágenes de menores
|
||||
@@ -56,6 +57,8 @@ es:
|
||||
s5_row5_basis: Ejecución del contrato
|
||||
s5_row6_purpose: Marketing directo (solo si está previsto y con consentimiento cuando se requiera)
|
||||
s5_row6_basis: Consentimiento o interés legítimo, según los casos
|
||||
s5_row7_purpose: Gestión de las solicitudes enviadas desde el formulario de contacto
|
||||
s5_row7_basis: Medidas precontractuales; interés legítimo en responder a las solicitudes
|
||||
s6_title: 6. Destinatarios y encargados del tratamiento
|
||||
s6_intro: "Los datos pueden comunicarse a proveedores que actúan como encargados del tratamiento, entre ellos:"
|
||||
s6_item1: proveedor de alojamiento e infraestructura en la nube/servidor;
|
||||
@@ -72,6 +75,7 @@ es:
|
||||
s8_item2_html: "<strong>Grabaciones de partidos (repeticiones):</strong> según el plan (por ejemplo, 30 o 90 días), después eliminación automática o a petición."
|
||||
s8_item3_html: "<strong>Registros técnicos:</strong> periodo limitado (por ejemplo, 30-90 días) salvo obligaciones de seguridad."
|
||||
s8_item4_html: "<strong>Token de restablecimiento de contraseña:</strong> validez máxima %{hours} horas."
|
||||
s8_item5_html: "<strong>Solicitudes del formulario de contacto:</strong> se conservan solo el tiempo necesario para gestionar la solicitud y luego se eliminan del buzón operativo."
|
||||
s9_title: 9. Derechos de los interesados
|
||||
s9_intro: "Como interesado tienes derecho a:"
|
||||
s9_item1: acceder a tus datos y obtener una copia de ellos;
|
||||
|
||||
@@ -28,6 +28,7 @@ fr:
|
||||
s3_item4_html: "<strong>Contenus audiovisuels :</strong> flux vidéo et audio des matchs diffusés en direct et, avec les forfaits Premium, enregistrements (replays) conservés pendant une durée limitée."
|
||||
s3_item5_html: "<strong>Données de paiement :</strong> pour les forfaits payants, Stripe traite les données nécessaires au paiement (par ex. mode de paiement, coordonnées de facturation). Le responsable ne conserve pas les numéros complets de carte."
|
||||
s3_item6_html: "<strong>Données de navigation :</strong> journaux du serveur, adresse IP, user agent, nécessaires à la sécurité et au fonctionnement du site."
|
||||
s3_item7_html: "<strong>Données du formulaire de contact :</strong> nom, email, club éventuel, motif et message envoyés depuis la page Contact."
|
||||
s4_title: 4. Traitement des images et données des athlètes mineurs
|
||||
s4_p1_html: "Match Live TV est également destiné aux compétitions et catégories jeunes (par ex. U14, U16, U18). Les directs peuvent filmer des <strong>athlètes mineurs</strong> identifiables (image, voix, numéro de maillot, données de jeu)."
|
||||
s4_lead1: Qui est responsable du traitement des images des mineurs
|
||||
@@ -56,6 +57,8 @@ fr:
|
||||
s5_row5_basis: Exécution du contrat
|
||||
s5_row6_purpose: Marketing direct (uniquement si prévu et avec consentement le cas échéant)
|
||||
s5_row6_basis: Consentement ou intérêt légitime, selon les cas
|
||||
s5_row7_purpose: Traitement des demandes envoyées via le formulaire de contact
|
||||
s5_row7_basis: Mesures précontractuelles ; intérêt légitime à répondre aux demandes
|
||||
s6_title: 6. Destinataires et sous-traitants
|
||||
s6_intro: "Les données peuvent être communiquées à des prestataires agissant en tant que sous-traitants, notamment :"
|
||||
s6_item1: fournisseur d'hébergement et d'infrastructure cloud/serveur ;
|
||||
@@ -72,6 +75,7 @@ fr:
|
||||
s8_item2_html: "<strong>Enregistrements des matchs (replays) :</strong> selon le forfait (par ex. 30 ou 90 jours), puis suppression automatique ou sur demande."
|
||||
s8_item3_html: "<strong>Journaux techniques :</strong> durée limitée (par ex. 30 à 90 jours) sauf obligations de sécurité."
|
||||
s8_item4_html: "<strong>Jeton de réinitialisation du mot de passe :</strong> validité maximale %{hours} heures."
|
||||
s8_item5_html: "<strong>Demandes du formulaire de contact :</strong> conservées uniquement le temps nécessaire au traitement de la demande, puis supprimées de la boîte mail opérationnelle."
|
||||
s9_title: 9. Droits des personnes concernées
|
||||
s9_intro: "En tant que personne concernée, vous avez le droit :"
|
||||
s9_item1: d'accéder à vos données et d'en obtenir une copie ;
|
||||
|
||||
@@ -28,6 +28,7 @@ it:
|
||||
s3_item4_html: "<strong>Contenuti audiovisivi:</strong> flussi video e audio delle partite trasmesse in diretta e, con i piani Premium, registrazioni (replay) conservate per un periodo limitato."
|
||||
s3_item5_html: "<strong>Dati di pagamento:</strong> per i piani a pagamento, Stripe tratta i dati necessari al pagamento (es. metodo di pagamento, intestazione fattura). Il titolare non conserva i numeri completi della carta."
|
||||
s3_item6_html: "<strong>Dati di navigazione:</strong> log del server, indirizzo IP, user agent, necessari per sicurezza e funzionamento del sito."
|
||||
s3_item7_html: "<strong>Dati del modulo contatti:</strong> nome, email, eventuale società, motivo e messaggio inviati dalla pagina Contatti."
|
||||
s4_title: 4. Trattamento di immagini e dati di atleti minorenni
|
||||
s4_p1_html: "Match Live TV è destinato anche a competizioni e categorie giovanili (es. Under 14, Under 16, Under 18). Le dirette possono riprendere <strong>atleti minorenni</strong> identificabili (immagine, voce, numero di maglia, dati di gioco)."
|
||||
s4_lead1: Chi è responsabile del trattamento delle immagini dei minori
|
||||
@@ -56,6 +57,8 @@ it:
|
||||
s5_row5_basis: Esecuzione del contratto
|
||||
s5_row6_purpose: Marketing diretto (solo se previsto e con consenso ove richiesto)
|
||||
s5_row6_basis: Consenso o legittimo interesse, secondo i casi
|
||||
s5_row7_purpose: Gestione delle richieste inviate dal modulo contatti
|
||||
s5_row7_basis: Misure precontrattuali; legittimo interesse a rispondere alle richieste
|
||||
s6_title: 6. Destinatari e responsabili del trattamento
|
||||
s6_intro: "I dati possono essere comunicati a fornitori che agiscono come responsabili del trattamento, tra cui:"
|
||||
s6_item1: fornitore di hosting e infrastruttura cloud/server;
|
||||
@@ -72,6 +75,7 @@ it:
|
||||
s8_item2_html: "<strong>Registrazioni partite (replay):</strong> secondo il piano (es. 30 o 90 giorni), poi cancellazione automatica o su richiesta."
|
||||
s8_item3_html: "<strong>Log tecnici:</strong> periodo limitato (es. 30–90 giorni) salvo obblighi di sicurezza."
|
||||
s8_item4_html: "<strong>Token reset password:</strong> validità massima %{hours} ore."
|
||||
s8_item5_html: "<strong>Richieste dal modulo contatti:</strong> conservate solo per il tempo necessario a gestire la richiesta, poi eliminate dalla casella di posta operativa."
|
||||
s9_title: 9. Diritti degli interessati
|
||||
s9_intro: "In qualità di interessato hai diritto di:"
|
||||
s9_item1: accedere ai tuoi dati e ottenerne copia;
|
||||
|
||||
@@ -9,6 +9,23 @@ de:
|
||||
expiry_html: "Der Link ist %{hours} Stunden gültig. Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail — dein Passwort wird nicht geändert."
|
||||
ignore: Wenn du das Zurücksetzen nicht angefordert hast, ignoriere diese E-Mail.
|
||||
footer: "Match Live TV — %{email}"
|
||||
contact:
|
||||
subject:
|
||||
commercial: "Vertriebsanfrage — %{name}"
|
||||
support: "Supportanfrage — %{name}"
|
||||
privacy: "Datenschutzanfrage — %{name}"
|
||||
heading: Neue Anfrage von der Website
|
||||
name: "Name:"
|
||||
email: "E-Mail:"
|
||||
club: "Verein:"
|
||||
club_none: Nicht angegeben
|
||||
topic: "Anliegen:"
|
||||
topic_labels:
|
||||
commercial: Vertrieb / Angebot
|
||||
support: Technischer Support
|
||||
privacy: Datenschutz / DSGVO
|
||||
message: "Nachricht:"
|
||||
footer: Match Live TV — Kontaktseite
|
||||
replay_ready:
|
||||
subject: "Replay bereit — %{title}"
|
||||
hello: "Hallo %{name},"
|
||||
|
||||
@@ -9,6 +9,23 @@ en:
|
||||
expiry_html: "The link is valid for %{hours} hours. If you did not request this reset, ignore this email — your password will not change."
|
||||
ignore: If you did not request this reset, ignore this email.
|
||||
footer: "Match Live TV — %{email}"
|
||||
contact:
|
||||
subject:
|
||||
commercial: "Sales enquiry — %{name}"
|
||||
support: "Support enquiry — %{name}"
|
||||
privacy: "Privacy enquiry — %{name}"
|
||||
heading: New website enquiry
|
||||
name: "Name:"
|
||||
email: "Email:"
|
||||
club: "Club:"
|
||||
club_none: Not provided
|
||||
topic: "Topic:"
|
||||
topic_labels:
|
||||
commercial: Sales / quote
|
||||
support: Technical support
|
||||
privacy: Privacy / GDPR
|
||||
message: "Message:"
|
||||
footer: Match Live TV — Contact page
|
||||
replay_ready:
|
||||
subject: "Replay ready — %{title}"
|
||||
hello: "Hi %{name},"
|
||||
|
||||
@@ -9,6 +9,23 @@ es:
|
||||
expiry_html: "El enlace es válido durante %{hours} horas. Si no solicitaste el restablecimiento, ignora este correo: la contraseña no se modificará."
|
||||
ignore: Si no solicitaste el restablecimiento, ignora este correo.
|
||||
footer: "Match Live TV — %{email}"
|
||||
contact:
|
||||
subject:
|
||||
commercial: "Consulta comercial — %{name}"
|
||||
support: "Consulta de asistencia — %{name}"
|
||||
privacy: "Consulta de privacidad — %{name}"
|
||||
heading: Nueva solicitud desde el sitio
|
||||
name: "Nombre:"
|
||||
email: "Email:"
|
||||
club: "Club:"
|
||||
club_none: No indicado
|
||||
topic: "Motivo:"
|
||||
topic_labels:
|
||||
commercial: Comercial / presupuesto
|
||||
support: Asistencia técnica
|
||||
privacy: Privacidad / RGPD
|
||||
message: "Mensaje:"
|
||||
footer: Match Live TV — página de Contacto
|
||||
replay_ready:
|
||||
subject: "Replay listo — %{title}"
|
||||
hello: "Hola %{name},"
|
||||
|
||||
@@ -9,6 +9,23 @@ fr:
|
||||
expiry_html: "Le lien est valable %{hours} heures. Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail : le mot de passe ne sera pas modifié."
|
||||
ignore: Si vous n’avez pas demandé cette réinitialisation, ignorez cet e-mail.
|
||||
footer: "Match Live TV — %{email}"
|
||||
contact:
|
||||
subject:
|
||||
commercial: "Demande commerciale — %{name}"
|
||||
support: "Demande d'assistance — %{name}"
|
||||
privacy: "Demande privacy — %{name}"
|
||||
heading: Nouvelle demande depuis le site
|
||||
name: "Nom :"
|
||||
email: "Email :"
|
||||
club: "Club :"
|
||||
club_none: Non indiqué
|
||||
topic: "Motif :"
|
||||
topic_labels:
|
||||
commercial: Commercial / devis
|
||||
support: Assistance technique
|
||||
privacy: Confidentialité / RGPD
|
||||
message: "Message :"
|
||||
footer: Match Live TV — page Contact
|
||||
replay_ready:
|
||||
subject: "Replay prêt — %{title}"
|
||||
hello: "Bonjour %{name},"
|
||||
|
||||
@@ -9,6 +9,23 @@ it:
|
||||
expiry_html: "Il link è valido per %{hours} ore. Se non hai richiesto tu il reset, ignora questa email: la password non verrà modificata."
|
||||
ignore: Se non hai richiesto tu il reset, ignora questa email.
|
||||
footer: "Match Live TV — %{email}"
|
||||
contact:
|
||||
subject:
|
||||
commercial: "Richiesta commerciale — %{name}"
|
||||
support: "Richiesta assistenza — %{name}"
|
||||
privacy: "Richiesta privacy — %{name}"
|
||||
heading: Nuova richiesta dal sito
|
||||
name: "Nome:"
|
||||
email: "Email:"
|
||||
club: "Società:"
|
||||
club_none: Non indicata
|
||||
topic: "Motivo:"
|
||||
topic_labels:
|
||||
commercial: Commerciale / preventivo
|
||||
support: Assistenza tecnica
|
||||
privacy: Privacy / GDPR
|
||||
message: "Messaggio:"
|
||||
footer: Match Live TV — pagina Contatti
|
||||
replay_ready:
|
||||
subject: "Replay pronto — %{title}"
|
||||
hello: "Ciao %{name},"
|
||||
|
||||
@@ -175,3 +175,40 @@ de:
|
||||
card3_body: "Premium Light/Full: Replay für alle, die den Livestream verpasst haben oder einen wichtigen Moment erneut sehen möchten."
|
||||
cta_start: Kostenlos starten
|
||||
cta_faq: Häufige Fragen
|
||||
contacts:
|
||||
meta_title: "Kontakt — Match Live TV"
|
||||
meta_description: "Kontakt zu Match Live TV: Angebote für Sportvereine, technischer Support und Datenschutzanfragen. Sitz in Mailand."
|
||||
eyebrow: Match Live TV
|
||||
title: Kontakt
|
||||
lead: Schreib uns für ein Angebot, App-Support oder eine Datenschutzanfrage. Wir antworten in der Regel innerhalb eines Werktags.
|
||||
response_time: "Typische Antwortzeit: 1 Werktag"
|
||||
commercial_title: Vertrieb
|
||||
commercial_body: Maßgeschneiderte Tarife, mehrere Teams, Turniere oder Integrationen. Schreib an
|
||||
support_title: Support
|
||||
support_body_html: "Probleme mit Zugang, Livestream oder App. Sieh zuerst die %{faq_link} oder die Seite %{support_link} an oder schreib an"
|
||||
support_link: Support
|
||||
faq_link: FAQ
|
||||
privacy_title: Datenschutz
|
||||
privacy_body: DSGVO-Rechte, Einwilligung und personenbezogene Daten. Schreib an
|
||||
company_title: Sitz
|
||||
company_lead: Anbieter des Dienstes und Verantwortlicher für die Datenverarbeitung.
|
||||
label_address: Adresse
|
||||
label_vat: USt-IdNr.
|
||||
form_title: Nachricht senden
|
||||
form_lead: "Fülle das Formular aus: Wir antworten an die angegebene E-Mail-Adresse."
|
||||
name_label: Name
|
||||
email_label: E-Mail
|
||||
club_label: Sportverein (optional)
|
||||
topic_label: Anliegen
|
||||
topic_commercial: Vertrieb / Angebot
|
||||
topic_support: Technischer Support
|
||||
topic_privacy: Datenschutz / DSGVO
|
||||
message_label: Nachricht
|
||||
honeypot_label: Website
|
||||
privacy_accept_html: Ich stimme der Verarbeitung meiner Daten zur Bearbeitung dieser Anfrage zu, wie in der %{privacy_link} beschrieben.
|
||||
privacy_link: Datenschutzerklärung
|
||||
submit: Nachricht senden
|
||||
cta_title: Lieber selbst nachsehen?
|
||||
cta_body: Viele Antworten stehen bereits in den FAQ. Um mit dem Streamen zu starten, registriere den Verein.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Team registrieren
|
||||
|
||||
@@ -175,3 +175,40 @@ en:
|
||||
card3_body: "Premium Light/Full: replay for those who missed the stream or want to rewatch a key point."
|
||||
cta_start: Start for free
|
||||
cta_faq: Frequently asked questions
|
||||
contacts:
|
||||
meta_title: "Contact — Match Live TV"
|
||||
meta_description: "Contact Match Live TV: quotes for sports clubs, technical support and privacy requests. Based in Milan."
|
||||
eyebrow: Match Live TV
|
||||
title: Contact
|
||||
lead: Write to us for a quote, app support or a privacy request. We usually reply within one working day.
|
||||
response_time: "Typical reply time: 1 working day"
|
||||
commercial_title: Sales
|
||||
commercial_body: Custom plans, multiple teams, tournaments or integrations. Write to
|
||||
support_title: Support
|
||||
support_body_html: "Sign-in, live stream or app issues. Check the %{faq_link} or the %{support_link} page first, or write to"
|
||||
support_link: Support
|
||||
faq_link: FAQ
|
||||
privacy_title: Privacy
|
||||
privacy_body: GDPR rights, consent and personal data. Write to
|
||||
company_title: Company details
|
||||
company_lead: Service provider and data controller.
|
||||
label_address: Address
|
||||
label_vat: VAT number
|
||||
form_title: Send a message
|
||||
form_lead: "Fill in the form and we will reply to the email you provide."
|
||||
name_label: Name
|
||||
email_label: Email
|
||||
club_label: Sports club (optional)
|
||||
topic_label: Topic
|
||||
topic_commercial: Sales / quote
|
||||
topic_support: Technical support
|
||||
topic_privacy: Privacy / GDPR
|
||||
message_label: Message
|
||||
honeypot_label: Website
|
||||
privacy_accept_html: I agree to the processing of my data to handle this request, as described in the %{privacy_link}.
|
||||
privacy_link: privacy notice
|
||||
submit: Send message
|
||||
cta_title: Prefer to look it up yourself?
|
||||
cta_body: Many answers are already in the FAQ. To start streaming, register your club.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Register your team
|
||||
|
||||
@@ -175,3 +175,40 @@ es:
|
||||
card3_body: "Premium Light/Full: repetición para quien se perdió el directo o quiere volver a ver un punto clave."
|
||||
cta_start: Empieza gratis
|
||||
cta_faq: Preguntas frecuentes
|
||||
contacts:
|
||||
meta_title: "Contacto — Match Live TV"
|
||||
meta_description: "Contacta con Match Live TV: presupuestos para clubes deportivos, soporte técnico y solicitudes de privacidad. Sede en Milán."
|
||||
eyebrow: Match Live TV
|
||||
title: Contacto
|
||||
lead: Escríbenos para un presupuesto, para ayuda con la app o para una solicitud de privacidad. Solemos responder en un día laborable.
|
||||
response_time: "Tiempo de respuesta orientativo: 1 día laborable"
|
||||
commercial_title: Comercial
|
||||
commercial_body: Planes a medida, varios equipos, torneos o integraciones. Escribe a
|
||||
support_title: Asistencia
|
||||
support_body_html: "Problemas de acceso, directos o app. Consulta primero las %{faq_link} o la página %{support_link}, o escribe a"
|
||||
support_link: Soporte
|
||||
faq_link: FAQ
|
||||
privacy_title: Privacidad
|
||||
privacy_body: Derechos RGPD, consentimiento y datos personales. Escribe a
|
||||
company_title: Sede
|
||||
company_lead: Prestador del servicio y responsable del tratamiento.
|
||||
label_address: Dirección
|
||||
label_vat: NIF / IVA
|
||||
form_title: Enviar un mensaje
|
||||
form_lead: "Completa el formulario: te responderemos al email que indiques."
|
||||
name_label: Nombre
|
||||
email_label: Email
|
||||
club_label: Club deportivo (opcional)
|
||||
topic_label: Motivo
|
||||
topic_commercial: Comercial / presupuesto
|
||||
topic_support: Asistencia técnica
|
||||
topic_privacy: Privacidad / RGPD
|
||||
message_label: Mensaje
|
||||
honeypot_label: Sitio web
|
||||
privacy_accept_html: Acepto el tratamiento de los datos para gestionar esta solicitud, como se describe en la %{privacy_link}.
|
||||
privacy_link: política de privacidad
|
||||
submit: Enviar mensaje
|
||||
cta_title: "¿Prefieres buscarlo tú?"
|
||||
cta_body: Muchas respuestas ya están en las FAQ. Para empezar a emitir, registra el club.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Registrar el equipo
|
||||
|
||||
@@ -175,3 +175,40 @@ fr:
|
||||
card3_body: "Premium Light/Full : replay pour ceux qui ont raté le direct ou veulent revoir un point clé."
|
||||
cta_start: Commencer gratuitement
|
||||
cta_faq: Questions fréquentes
|
||||
contacts:
|
||||
meta_title: "Contact — Match Live TV"
|
||||
meta_description: "Contactez Match Live TV : devis pour clubs sportifs, assistance technique et demandes privacy. Siège à Milan."
|
||||
eyebrow: Match Live TV
|
||||
title: Contact
|
||||
lead: Écrivez-nous pour un devis, pour l'assistance de l'app ou pour une demande privacy. Nous répondons en général sous un jour ouvrable.
|
||||
response_time: "Délai indicatif : 1 jour ouvrable"
|
||||
commercial_title: Commercial
|
||||
commercial_body: Offres sur mesure, plusieurs équipes, tournois ou intégrations. Écrivez à
|
||||
support_title: Assistance
|
||||
support_body_html: "Problèmes d'accès, de direct ou d'application. Consultez d'abord les %{faq_link} ou la page %{support_link}, ou écrivez à"
|
||||
support_link: Support
|
||||
faq_link: FAQ
|
||||
privacy_title: Confidentialité
|
||||
privacy_body: Droits RGPD, consentement et données personnelles. Écrivez à
|
||||
company_title: Siège
|
||||
company_lead: Prestataire du service et responsable du traitement.
|
||||
label_address: Adresse
|
||||
label_vat: N° de TVA
|
||||
form_title: Envoyer un message
|
||||
form_lead: "Remplissez le formulaire : nous vous répondons à l'email indiqué."
|
||||
name_label: Nom
|
||||
email_label: Email
|
||||
club_label: Club sportif (facultatif)
|
||||
topic_label: Motif
|
||||
topic_commercial: Commercial / devis
|
||||
topic_support: Assistance technique
|
||||
topic_privacy: Confidentialité / RGPD
|
||||
message_label: Message
|
||||
honeypot_label: Site web
|
||||
privacy_accept_html: J'accepte le traitement des données pour gérer cette demande, comme décrit dans la %{privacy_link}.
|
||||
privacy_link: notice de confidentialité
|
||||
submit: Envoyer le message
|
||||
cta_title: Vous préférez chercher vous-même ?
|
||||
cta_body: Beaucoup de réponses sont déjà dans la FAQ. Pour commencer à diffuser, inscrivez le club.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Enregistrer l'équipe
|
||||
|
||||
@@ -175,3 +175,40 @@ it:
|
||||
card3_body: "Premium Light/Full: replay per chi ha perso la diretta o vuole rivedere un punto chiave."
|
||||
cta_start: Inizia gratis
|
||||
cta_faq: Domande frequenti
|
||||
contacts:
|
||||
meta_title: "Contatti — Match Live TV"
|
||||
meta_description: "Contatta Match Live TV: preventivi per società sportive, assistenza tecnica e richieste privacy. Sede a Milano."
|
||||
eyebrow: Match Live TV
|
||||
title: Contatti
|
||||
lead: Scrivici per un preventivo, per l'assistenza sull'app o per una richiesta privacy. Rispondiamo in genere entro un giorno lavorativo.
|
||||
response_time: "Risposta indicativa: 1 giorno lavorativo"
|
||||
commercial_title: Commerciale
|
||||
commercial_body: Piani su misura, più squadre, tornei o integrazioni. Scrivi a
|
||||
support_title: Assistenza
|
||||
support_body_html: "Problemi di accesso, dirette o app. Consulta prima le %{faq_link} o la pagina %{support_link}, oppure scrivi a"
|
||||
support_link: Supporto
|
||||
faq_link: FAQ
|
||||
privacy_title: Privacy
|
||||
privacy_body: Diritti GDPR, consenso e dati personali. Scrivi a
|
||||
company_title: Sede
|
||||
company_lead: Titolare del servizio e del trattamento dei dati.
|
||||
label_address: Indirizzo
|
||||
label_vat: Partita IVA
|
||||
form_title: Invia un messaggio
|
||||
form_lead: "Compila il modulo: ti rispondiamo all'email che indichi."
|
||||
name_label: Nome
|
||||
email_label: Email
|
||||
club_label: Società sportiva (opzionale)
|
||||
topic_label: Motivo
|
||||
topic_commercial: Commerciale / preventivo
|
||||
topic_support: Assistenza tecnica
|
||||
topic_privacy: Privacy / GDPR
|
||||
message_label: Messaggio
|
||||
honeypot_label: Sito web
|
||||
privacy_accept_html: Acconsento al trattamento dei dati per gestire questa richiesta, come descritto nella %{privacy_link}.
|
||||
privacy_link: informativa privacy
|
||||
submit: Invia messaggio
|
||||
cta_title: Preferisci fare da solo?
|
||||
cta_body: Molte risposte sono già nelle FAQ. Per iniziare a trasmettere, registra la società.
|
||||
cta_faq: FAQ
|
||||
cta_signup: Registra la squadra
|
||||
|
||||
@@ -141,6 +141,7 @@ de:
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
support: Support
|
||||
contacts: Kontakt
|
||||
cookies: Cookies
|
||||
terms: AGB
|
||||
pricing: Preise
|
||||
@@ -174,3 +175,7 @@ de:
|
||||
choice_youtube_hint_only: Entfernt nur das YouTube-Video
|
||||
choice_both_label: Website und YouTube
|
||||
choice_both_hint: Vollständige Löschung
|
||||
announcement:
|
||||
region: Hinweise
|
||||
dismiss: Hinweis schließen
|
||||
open: Öffnen
|
||||
|
||||
@@ -141,6 +141,7 @@ en:
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Support
|
||||
contacts: Contacts
|
||||
cookies: Cookies
|
||||
terms: Terms
|
||||
pricing: Pricing
|
||||
@@ -174,3 +175,7 @@ en:
|
||||
choice_youtube_hint_only: Removes only the YouTube video
|
||||
choice_both_label: Site and YouTube
|
||||
choice_both_hint: Complete deletion
|
||||
announcement:
|
||||
region: Notices
|
||||
dismiss: Dismiss notice
|
||||
open: Open
|
||||
|
||||
@@ -141,6 +141,7 @@ es:
|
||||
common:
|
||||
privacy: Privacidad
|
||||
support: Soporte
|
||||
contacts: Contacto
|
||||
cookies: Cookies
|
||||
terms: Términos
|
||||
pricing: Precios
|
||||
@@ -174,3 +175,7 @@ es:
|
||||
choice_youtube_hint_only: Elimina solo el vídeo de YouTube
|
||||
choice_both_label: Sitio y YouTube
|
||||
choice_both_hint: Eliminación completa
|
||||
announcement:
|
||||
region: Avisos
|
||||
dismiss: Cerrar aviso
|
||||
open: Abrir
|
||||
|
||||
@@ -141,6 +141,7 @@ fr:
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
support: Support
|
||||
contacts: Contacts
|
||||
cookies: Cookies
|
||||
terms: Conditions
|
||||
pricing: Tarifs
|
||||
@@ -174,3 +175,7 @@ fr:
|
||||
choice_youtube_hint_only: Supprime uniquement la vidéo YouTube
|
||||
choice_both_label: Site et YouTube
|
||||
choice_both_hint: Suppression complète
|
||||
announcement:
|
||||
region: Alertes
|
||||
dismiss: Fermer l'alerte
|
||||
open: Ouvrir
|
||||
|
||||
@@ -141,6 +141,7 @@ it:
|
||||
common:
|
||||
privacy: Privacy
|
||||
support: Supporto
|
||||
contacts: Contatti
|
||||
cookies: Cookie
|
||||
terms: Termini
|
||||
pricing: Prezzi
|
||||
@@ -174,3 +175,7 @@ it:
|
||||
choice_youtube_hint_only: Rimuove solo il video YouTube
|
||||
choice_both_label: Da sito e YouTube
|
||||
choice_both_hint: Eliminazione completa
|
||||
announcement:
|
||||
region: Avvisi
|
||||
dismiss: Chiudi avviso
|
||||
open: Apri
|
||||
|
||||
@@ -16,6 +16,7 @@ Rails.application.routes.draw do
|
||||
patch "account", to: "accounts#update"
|
||||
patch "account/password", to: "accounts#password"
|
||||
get "sports", to: "sports#index"
|
||||
get "announcements", to: "announcements#index"
|
||||
|
||||
get "invitations/:token", to: "invitations#show"
|
||||
post "invitations/:token/accept", to: "invitations#accept"
|
||||
@@ -84,6 +85,7 @@ Rails.application.routes.draw do
|
||||
|
||||
root to: "dashboard#index"
|
||||
get "metrics", to: "dashboard#metrics"
|
||||
resources :announcements, except: %i[show]
|
||||
resources :ops, only: [:index], controller: "ops" do
|
||||
member do
|
||||
post :acknowledge
|
||||
@@ -170,6 +172,9 @@ Rails.application.routes.draw do
|
||||
get "pallavolo-giovanile", to: "pages#pallavolo", as: :pallavolo
|
||||
get "prezzi", to: "pages#pricing", as: :prezzi
|
||||
get "pricing", to: redirect("/prezzi")
|
||||
get "contatti", to: "contacts#show", as: :contatti
|
||||
post "contatti", to: "contacts#create"
|
||||
get "contact", to: redirect("/contatti")
|
||||
get "privacy", to: "pages#privacy", as: :privacy
|
||||
get "support", to: "pages#support", as: :support
|
||||
get "cookie", to: "pages#cookies", as: :cookies
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
class CreateAppAnnouncements < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
create_table :app_announcements, id: :uuid do |t|
|
||||
t.string :kind, null: false, default: "info"
|
||||
t.string :severity, null: false, default: "info"
|
||||
t.string :status, null: false, default: "draft"
|
||||
t.boolean :show_on_app, null: false, default: true
|
||||
t.boolean :show_on_web_public, null: false, default: false
|
||||
t.boolean :show_on_web_private, null: false, default: false
|
||||
t.string :title, null: false
|
||||
t.text :body, null: false
|
||||
t.string :action_url
|
||||
t.string :action_label
|
||||
t.boolean :dismissible, null: false, default: true
|
||||
t.datetime :starts_at
|
||||
t.datetime :ends_at
|
||||
t.references :created_by_admin, foreign_key: { to_table: :admin_accounts }, type: :uuid
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :app_announcements, :status
|
||||
add_index :app_announcements, %i[status starts_at ends_at]
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,26 @@
|
||||
class SplitAnnouncementAppPlatforms < ActiveRecord::Migration[7.2]
|
||||
def change
|
||||
add_column :app_announcements, :show_on_android, :boolean, null: false, default: true
|
||||
add_column :app_announcements, :show_on_ios, :boolean, null: false, default: true
|
||||
add_column :app_announcements, :android_title, :string
|
||||
add_column :app_announcements, :android_body, :text
|
||||
add_column :app_announcements, :android_action_url, :string
|
||||
add_column :app_announcements, :android_action_label, :string
|
||||
add_column :app_announcements, :ios_title, :string
|
||||
add_column :app_announcements, :ios_body, :text
|
||||
add_column :app_announcements, :ios_action_url, :string
|
||||
add_column :app_announcements, :ios_action_label, :string
|
||||
|
||||
reversible do |dir|
|
||||
dir.up do
|
||||
execute <<~SQL.squish
|
||||
UPDATE app_announcements
|
||||
SET show_on_android = show_on_app,
|
||||
show_on_ios = show_on_app
|
||||
SQL
|
||||
end
|
||||
end
|
||||
|
||||
remove_column :app_announcements, :show_on_app, :boolean, null: false, default: true
|
||||
end
|
||||
end
|
||||
@@ -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
+26
-1
@@ -10,7 +10,7 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
|
||||
ActiveRecord::Schema[7.2].define(version: 2026_08_20_191000) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pgcrypto"
|
||||
enable_extension "plpgsql"
|
||||
@@ -51,6 +51,30 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
|
||||
t.index ["username"], name: "index_admin_accounts_on_username", unique: true
|
||||
end
|
||||
|
||||
create_table "app_announcements", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.string "kind", default: "info", null: false
|
||||
t.string "severity", default: "info", null: false
|
||||
t.string "status", default: "draft", null: false
|
||||
t.boolean "show_on_web_public", default: false, null: false
|
||||
t.boolean "show_on_web_private", default: false, null: false
|
||||
t.string "title", null: false
|
||||
t.text "body", null: false
|
||||
t.string "action_url"
|
||||
t.string "action_label"
|
||||
t.boolean "dismissible", default: true, null: false
|
||||
t.datetime "starts_at"
|
||||
t.datetime "ends_at"
|
||||
t.uuid "created_by_admin_id"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "show_on_android", default: true, null: false
|
||||
t.boolean "show_on_ios", default: true, null: false
|
||||
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"
|
||||
end
|
||||
|
||||
create_table "billing_club_quotes", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||
t.uuid "club_id", null: false
|
||||
t.string "plan_slug", null: false
|
||||
@@ -471,6 +495,7 @@ ActiveRecord::Schema[7.2].define(version: 2026_08_18_220000) do
|
||||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "app_announcements", "admin_accounts", column: "created_by_admin_id"
|
||||
add_foreign_key "billing_club_quotes", "admin_accounts", column: "created_by_admin_id"
|
||||
add_foreign_key "billing_club_quotes", "clubs"
|
||||
add_foreign_key "billing_invoices", "billing_payments"
|
||||
|
||||
@@ -206,6 +206,7 @@ body.admin-body {
|
||||
.admin-table td {
|
||||
padding: 0.65rem 0.5rem;
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
border-bottom: 1px solid var(--card-border);
|
||||
}
|
||||
|
||||
@@ -230,6 +231,21 @@ body.admin-body {
|
||||
.badge--paused { background: #555; color: #fff; }
|
||||
.badge--ready { background: #1b5e20; color: #c8e6c9; }
|
||||
.badge--ok { background: #1b5e20; color: #c8e6c9; }
|
||||
.badge--ingest-home { background: #1565c0; color: #e3f2fd; }
|
||||
.badge--ingest-lab { background: #6a1b9a; color: #f3e5f5; }
|
||||
.badge--ingest-cloud { background: #e65100; color: #fff3e0; }
|
||||
|
||||
.admin-ingest {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.admin-ingest__slug {
|
||||
font-size: 0.85rem;
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.team-list {
|
||||
list-style: none;
|
||||
@@ -307,9 +323,11 @@ body.admin-body {
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* Flex sul contenuto, non sulla <td>: altrimenti si rompe vertical-align della riga. */
|
||||
.admin-link-compact {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
@@ -582,3 +600,116 @@ body.admin-body {
|
||||
color: var(--red) !important;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: grid;
|
||||
gap: 0.9rem;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.admin-form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.admin-form label {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.admin-form input:not([type="checkbox"]),
|
||||
.admin-form select,
|
||||
.admin-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 6px;
|
||||
background: #1a1a22;
|
||||
color: #eee;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.admin-form textarea {
|
||||
min-height: 7rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-form-hint {
|
||||
margin: -0.4rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.admin-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.admin-checkbox input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.admin-channels {
|
||||
margin: 0;
|
||||
padding: 0.85rem 1rem;
|
||||
border: 1px solid #444;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.admin-channels legend {
|
||||
padding: 0 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.admin-locale-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-locale-panel[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-form-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.admin-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,9 @@ body {
|
||||
a { color: #ff6b6b; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.wrap { max-width: 1100px; margin: 0 auto; padding: 0 20px 48px; }
|
||||
/* Form portale (società/squadra + copertina 16:9): usa tutta la larghezza desktop, non stile “mobile”. */
|
||||
.wrap--portal { max-width: 1100px; }
|
||||
.wrap--portal .card { max-width: none; }
|
||||
.site-chrome {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
@@ -450,15 +453,19 @@ body.nav-menu-open { overflow: hidden; }
|
||||
object-fit: contain;
|
||||
}
|
||||
.branding-cover-preview {
|
||||
min-height: 140px;
|
||||
min-height: 0;
|
||||
aspect-ratio: 16 / 9;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.branding-cover-preview__img {
|
||||
max-width: 100%;
|
||||
max-height: 180px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
object-fit: contain;
|
||||
object-fit: cover;
|
||||
}
|
||||
.cover-remove-label {
|
||||
display: flex;
|
||||
@@ -468,6 +475,27 @@ body.nav-menu-open { overflow: hidden; }
|
||||
font-size: 0.9rem;
|
||||
color: #aaa;
|
||||
}
|
||||
.cover-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.cover-reset-btn {
|
||||
padding: 10px 16px;
|
||||
font-size: 0.9rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cover-reset-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
.cover-reset-hint {
|
||||
flex: 1 1 100%;
|
||||
margin: 0;
|
||||
color: #a5f0b8;
|
||||
}
|
||||
.branding-file-input { margin-bottom: 0 !important; }
|
||||
.branding-url-input { margin-bottom: 14px; }
|
||||
.branding-color-row {
|
||||
@@ -3257,3 +3285,183 @@ a.replay-archive__thumb:hover {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.site-announcements {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
padding: 12px 20px 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.live-main .site-announcements {
|
||||
max-width: none;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.site-announcement {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
border-radius: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid #3a3a48;
|
||||
background: #16161e;
|
||||
}
|
||||
|
||||
.site-announcement__body {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.site-announcement__title {
|
||||
margin: 0 0 4px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-announcement__text {
|
||||
margin: 0;
|
||||
color: #c8c8d4;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.site-announcement__text p {
|
||||
margin: 0 0 0.4em;
|
||||
}
|
||||
|
||||
.site-announcement__text p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.site-announcement__action {
|
||||
margin: 8px 0 0;
|
||||
}
|
||||
|
||||
.site-announcement__link {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.site-announcement__dismiss {
|
||||
flex-shrink: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #9a9aad;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.site-announcement__dismiss:hover {
|
||||
color: #fff;
|
||||
background: #2a2a36;
|
||||
}
|
||||
|
||||
.site-announcement--info {
|
||||
border-color: #e53935;
|
||||
background: rgba(229, 57, 53, 0.12);
|
||||
}
|
||||
|
||||
.site-announcement--maintenance,
|
||||
.site-announcement--warning {
|
||||
border-color: #f5c518;
|
||||
background: rgba(245, 197, 24, 0.12);
|
||||
}
|
||||
|
||||
.site-announcement--update,
|
||||
.site-announcement--success {
|
||||
border-color: #22c55e;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
}
|
||||
|
||||
.site-announcement--critical {
|
||||
border-color: #e53935;
|
||||
background: rgba(229, 57, 53, 0.16);
|
||||
}
|
||||
|
||||
.contacts-hero h1 {
|
||||
max-width: 22ch;
|
||||
}
|
||||
.contacts-hero__meta {
|
||||
margin: 0;
|
||||
color: #888;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
.contacts-channels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.contacts-channels .feature-card h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
.contacts-channels__email {
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.contacts-channels__email a {
|
||||
color: #e53935;
|
||||
font-weight: 600;
|
||||
word-break: break-all;
|
||||
}
|
||||
.contacts-company__details {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
color: #ccc;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.contacts-form-panel {
|
||||
max-width: 640px;
|
||||
margin: 0 auto;
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a36;
|
||||
border-radius: 14px;
|
||||
padding: 28px 24px;
|
||||
}
|
||||
.contacts-form-panel h2 { margin: 0 0 10px; }
|
||||
.contacts-form-panel__lead {
|
||||
margin: 0 0 20px;
|
||||
color: #aaa;
|
||||
line-height: 1.55;
|
||||
}
|
||||
.contacts-form textarea {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3d3d4a;
|
||||
background: #1c1c26;
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
min-height: 140px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.contacts-form textarea:focus {
|
||||
outline: none;
|
||||
border-color: #e53935;
|
||||
box-shadow: 0 0 0 2px rgba(229, 57, 53, 0.25);
|
||||
}
|
||||
.contacts-form .btn-primary {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.contacts-hp {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (max-width: 999px) {
|
||||
.contacts-channels {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe ContactMailer, type: :mailer do
|
||||
it "invia la richiesta commerciale al supporto con reply-to" do
|
||||
mail = described_class.inquiry(
|
||||
name: "Anna Bianchi",
|
||||
email: "anna@test.it",
|
||||
club_name: "ASD Test",
|
||||
topic: "commercial",
|
||||
message: "Serve un piano per due palestre."
|
||||
)
|
||||
|
||||
expect(mail.to).to eq([MatchLiveTv.support_email])
|
||||
expect(mail.reply_to).to eq(["anna@test.it"])
|
||||
expect(mail.subject).to include("Anna Bianchi")
|
||||
expect(mail.body.encoded).to include("ASD Test")
|
||||
expect(mail.body.encoded).to include("due palestre")
|
||||
end
|
||||
|
||||
it "invia la richiesta privacy all'indirizzo privacy" do
|
||||
mail = described_class.inquiry(
|
||||
name: "Luca Verdi",
|
||||
email: "luca@test.it",
|
||||
club_name: "",
|
||||
topic: "privacy",
|
||||
message: "Chiedo la cancellazione dei dati."
|
||||
)
|
||||
|
||||
expect(mail.to).to eq([MatchLiveTv.privacy_controller_email])
|
||||
expect(mail.body.encoded).to include(I18n.t("mailers.contact.club_none", locale: :it))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe AppAnnouncement do
|
||||
def create_notice!(**attrs)
|
||||
described_class.create!({
|
||||
kind: "info",
|
||||
severity: "info",
|
||||
status: "published",
|
||||
title: "Avviso",
|
||||
body: "Testo",
|
||||
show_on_android: true,
|
||||
show_on_ios: true,
|
||||
show_on_web_public: false,
|
||||
show_on_web_private: false
|
||||
}.merge(attrs))
|
||||
end
|
||||
|
||||
it "is active on the app when published without a time window" do
|
||||
item = create_notice!(kind: "maintenance", severity: "warning", title: "Manutenzione", body: "Domani dalle 2 alle 4.")
|
||||
|
||||
expect(described_class.for_app).to include(item)
|
||||
expect(described_class.for_channel(:web_public)).not_to include(item)
|
||||
end
|
||||
|
||||
it "hides drafts and expired notices" do
|
||||
create_notice!(status: "draft", title: "Bozza", body: "Non visibile")
|
||||
create_notice!(title: "Scaduto", body: "Fine", ends_at: 1.hour.ago)
|
||||
live = create_notice!(kind: "update", title: "Aggiornamento", body: "Nuova versione")
|
||||
|
||||
expect(described_class.for_app.map(&:title)).to eq(["Aggiornamento"])
|
||||
expect(live.as_api_json(platform: "android")[:action_url]).to eq(MatchLiveTv.play_store_url)
|
||||
expect(live.as_api_json(platform: "ios")[:action_url]).to eq(MatchLiveTv.app_store_url)
|
||||
end
|
||||
|
||||
it "filters by selected channels" do
|
||||
public_item = create_notice!(
|
||||
title: "Pubblico", show_on_android: false, show_on_ios: false, show_on_web_public: true
|
||||
)
|
||||
private_item = create_notice!(
|
||||
title: "Privato", show_on_android: false, show_on_ios: false, show_on_web_private: true
|
||||
)
|
||||
android_item = create_notice!(title: "Android", show_on_ios: false)
|
||||
ios_item = create_notice!(title: "iOS", show_on_android: false, show_on_ios: true)
|
||||
|
||||
expect(described_class.for_channel(:web_public)).to contain_exactly(public_item)
|
||||
expect(described_class.for_channel(:web_private)).to contain_exactly(private_item)
|
||||
expect(described_class.for_platform("android")).to contain_exactly(android_item)
|
||||
expect(described_class.for_platform("ios")).to contain_exactly(ios_item)
|
||||
expect(described_class.for_app).to contain_exactly(android_item, ios_item)
|
||||
end
|
||||
|
||||
it "serves translated copy and falls back to English then Italian" do
|
||||
item = create_notice!(
|
||||
title: "Titolo IT",
|
||||
body: "Testo IT",
|
||||
translations: {
|
||||
"en" => { "title" => "EN title", "body" => "EN body" },
|
||||
"de" => { "title" => "DE title", "body" => "DE body" }
|
||||
}
|
||||
)
|
||||
|
||||
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
|
||||
item = described_class.new(
|
||||
kind: "info", status: "draft", title: "X", body: "Y",
|
||||
show_on_android: false, show_on_ios: false,
|
||||
show_on_web_public: false, show_on_web_private: false
|
||||
)
|
||||
expect(item).not_to be_valid
|
||||
expect(item.errors[:base]).to be_present
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,88 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Admin announcements", type: :request do
|
||||
let!(:admin) { AdminAccount.create!(username: "ops-news", password: "Password123") }
|
||||
|
||||
before do
|
||||
post admin_login_path, params: { username: "ops-news", password: "Password123" }
|
||||
end
|
||||
|
||||
it "crea e pubblica un avviso visibile alle app e al sito" do
|
||||
post admin_announcements_path, params: {
|
||||
app_announcement: {
|
||||
kind: "update",
|
||||
severity: "info",
|
||||
status: "published",
|
||||
title: "Nuova versione",
|
||||
body: "Aggiorna l'app dallo store.",
|
||||
dismissible: "1",
|
||||
show_on_android: "1",
|
||||
show_on_ios: "1",
|
||||
show_on_web_public: "1",
|
||||
show_on_web_private: "0"
|
||||
}
|
||||
}
|
||||
|
||||
expect(response).to redirect_to(admin_announcements_path)
|
||||
notice = AppAnnouncement.last
|
||||
expect(notice.title).to eq("Nuova versione")
|
||||
expect(notice).to be_published
|
||||
expect(notice.show_on_android).to be(true)
|
||||
expect(notice.show_on_ios).to be(true)
|
||||
expect(notice.show_on_web_public).to be(true)
|
||||
expect(notice.show_on_web_private).to be(false)
|
||||
|
||||
get admin_announcements_path
|
||||
expect(response.body).to include("Nuova versione")
|
||||
expect(response.body).to include(I18n.t("admin.announcements.channels.web_public"))
|
||||
end
|
||||
|
||||
it "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"
|
||||
)
|
||||
|
||||
delete admin_announcement_path(item)
|
||||
|
||||
expect(response).to redirect_to(admin_announcements_path)
|
||||
expect(AppAnnouncement.find_by(id: item.id)).to be_nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Api::V1::Announcements", type: :request do
|
||||
it "GET /api/v1/announcements senza autenticazione restituisce solo gli avvisi attivi per la piattaforma" do
|
||||
AppAnnouncement.create!(
|
||||
kind: "maintenance",
|
||||
severity: "warning",
|
||||
status: "published",
|
||||
title: "Manutenzione stasera",
|
||||
body: "Dalle 23:00 alle 01:00 il servizio sarà offline.",
|
||||
show_on_android: true,
|
||||
show_on_ios: true
|
||||
)
|
||||
AppAnnouncement.create!(
|
||||
kind: "info",
|
||||
status: "published",
|
||||
title: "Solo sito",
|
||||
body: "Non in app",
|
||||
show_on_android: false,
|
||||
show_on_ios: false,
|
||||
show_on_web_public: true
|
||||
)
|
||||
AppAnnouncement.create!(
|
||||
kind: "info",
|
||||
status: "draft",
|
||||
title: "Bozza",
|
||||
body: "Non deve uscire"
|
||||
)
|
||||
AppAnnouncement.create!(
|
||||
kind: "update",
|
||||
status: "published",
|
||||
title: "Aggiorna Android",
|
||||
body: "Versione nuova",
|
||||
show_on_android: true,
|
||||
show_on_ios: false
|
||||
)
|
||||
|
||||
get "/api/v1/announcements", params: { platform: "android" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
android_body = JSON.parse(response.body)
|
||||
expect(android_body.map { |row| row["title"] }).to contain_exactly("Manutenzione stasera", "Aggiorna Android")
|
||||
|
||||
get "/api/v1/announcements", params: { platform: "ios" }
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
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
|
||||
@@ -0,0 +1,66 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public site announcements", type: :request do
|
||||
let!(:coach) do
|
||||
User.create!(email: "news-coach@test.it", name: "Coach", password: "Password123", role: "coach")
|
||||
end
|
||||
|
||||
it "mostra gli avvisi pubblici in home e non quelli privati" do
|
||||
AppAnnouncement.create!(
|
||||
kind: "maintenance", status: "published", title: "Manutenzione sito",
|
||||
body: "Stasera il sito sarà offline.",
|
||||
show_on_android: false, show_on_ios: false, show_on_web_public: true, show_on_web_private: false
|
||||
)
|
||||
AppAnnouncement.create!(
|
||||
kind: "info", status: "published", title: "Solo soci",
|
||||
body: "Messaggio interno.",
|
||||
show_on_android: false, show_on_ios: false, show_on_web_public: false, show_on_web_private: true
|
||||
)
|
||||
|
||||
get root_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Manutenzione sito")
|
||||
expect(response.body).not_to include("Solo soci")
|
||||
end
|
||||
|
||||
it "mostra 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",
|
||||
body: "Messaggio interno.",
|
||||
show_on_android: false, show_on_ios: false, show_on_web_public: false, show_on_web_private: true
|
||||
)
|
||||
AppAnnouncement.create!(
|
||||
kind: "info", status: "published", title: "Manutenzione sito",
|
||||
body: "Stasera il sito sarà offline.",
|
||||
show_on_android: false, show_on_ios: false, show_on_web_public: true, show_on_web_private: false
|
||||
)
|
||||
|
||||
post public_login_path, params: { email: coach.email, password: "Password123" }
|
||||
get public_account_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Solo soci")
|
||||
expect(response.body).not_to include("Manutenzione sito")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,149 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Public contacts page", type: :request do
|
||||
let(:valid_params) do
|
||||
{
|
||||
name: "Mario Rossi",
|
||||
email: "mario@test.it",
|
||||
club_name: "Volley Club",
|
||||
topic: "commercial",
|
||||
message: "Vorremmo un preventivo per tre squadre della società.",
|
||||
accept_privacy: "1"
|
||||
}
|
||||
end
|
||||
|
||||
it "è pubblica, indicizzabile e mostra i recapiti" do
|
||||
get public_contatti_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(I18n.t("pages.contacts.title", locale: :it))
|
||||
expect(response.body).to include(MatchLiveTv.support_email)
|
||||
expect(response.body).to include(MatchLiveTv.privacy_controller_email)
|
||||
expect(response.body).to include(MatchLiveTv.privacy_controller_name)
|
||||
expect(response.body).to include(MatchLiveTv.privacy_controller_address)
|
||||
expect(response.body).to include('rel="canonical"')
|
||||
expect(response.body).to include(public_contatti_path)
|
||||
expect(response.body).to include(public_support_path)
|
||||
expect(response.body).to include(public_faq_path)
|
||||
expect(response.body).to include(public_signup_path)
|
||||
expect(response.body).not_to include("translation missing")
|
||||
end
|
||||
|
||||
it "appare nel footer del sito, non nel chrome App Store" do
|
||||
get root_path
|
||||
expect(response.body).to include(public_contatti_path)
|
||||
|
||||
get public_support_path
|
||||
expect(response.body).not_to include(public_contatti_path)
|
||||
end
|
||||
|
||||
it "collega il CTA Prezzi alla pagina contatti" do
|
||||
load Rails.root.join("db/seeds/plans.rb")
|
||||
get public_prezzi_path
|
||||
|
||||
expect(response.body).to include(public_contatti_path)
|
||||
expect(response.body).not_to include("mailto:info@matchlivetv.it")
|
||||
end
|
||||
|
||||
it "include /contatti nella sitemap e reindirizza /contact" do
|
||||
get "/sitemap.xml"
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("#{MatchLiveTv.app_public_url.chomp('/')}/contatti")
|
||||
|
||||
get "/contact"
|
||||
expect(response).to redirect_to("/contatti")
|
||||
end
|
||||
|
||||
{
|
||||
"it" => "Contatti",
|
||||
"en" => "Contact",
|
||||
"fr" => "Contact",
|
||||
"de" => "Kontakt",
|
||||
"es" => "Contacto"
|
||||
}.each do |locale, heading|
|
||||
it "renderizza correttamente in #{locale} senza translation missing" do
|
||||
cookies[:mltv_locale] = locale
|
||||
get public_contatti_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(heading)
|
||||
expect(response.body).not_to include("translation missing")
|
||||
end
|
||||
end
|
||||
|
||||
it "invia la mail commerciale a info@ con reply-to del mittente" do
|
||||
ActionMailer::Base.deliveries.clear
|
||||
|
||||
expect {
|
||||
post public_contatti_path, params: valid_params
|
||||
}.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||
|
||||
expect(response).to redirect_to(public_contatti_path)
|
||||
expect(flash[:notice]).to eq(I18n.t("flash.contacts.sent", locale: :it))
|
||||
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
expect(mail.to).to eq([MatchLiveTv.support_email])
|
||||
expect(mail.reply_to).to eq(["mario@test.it"])
|
||||
expect(mail.subject).to include("Mario Rossi")
|
||||
expect(mail.body.encoded).to include("Volley Club")
|
||||
expect(mail.body.encoded).to include("tre squadre")
|
||||
end
|
||||
|
||||
it "invia le richieste privacy all'email privacy" do
|
||||
ActionMailer::Base.deliveries.clear
|
||||
|
||||
post public_contatti_path, params: valid_params.merge(topic: "privacy")
|
||||
|
||||
mail = ActionMailer::Base.deliveries.last
|
||||
expect(mail.to).to eq([MatchLiveTv.privacy_controller_email])
|
||||
expect(mail.subject).to include("privacy")
|
||||
end
|
||||
|
||||
it "non invia mail se il honeypot è compilato, ma risponde come successo" do
|
||||
ActionMailer::Base.deliveries.clear
|
||||
|
||||
expect {
|
||||
post public_contatti_path, params: valid_params.merge(website: "http://spam.test")
|
||||
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||
|
||||
expect(response).to redirect_to(public_contatti_path)
|
||||
end
|
||||
|
||||
it "rifiuta un modulo incompleto" do
|
||||
ActionMailer::Base.deliveries.clear
|
||||
|
||||
post public_contatti_path, params: valid_params.merge(name: "", accept_privacy: "1")
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(ActionMailer::Base.deliveries).to be_empty
|
||||
expect(flash[:alert]).to eq(I18n.t("flash.contacts.invalid", locale: :it))
|
||||
end
|
||||
|
||||
it "rifiuta senza consenso privacy" do
|
||||
post public_contatti_path, params: valid_params.merge(accept_privacy: "0")
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(flash[:alert]).to eq(I18n.t("flash.contacts.privacy_required", locale: :it))
|
||||
end
|
||||
|
||||
it "limita gli invii ripetuti dallo stesso IP" do
|
||||
previous = Rails.cache
|
||||
Rails.cache = ActiveSupport::Cache::MemoryStore.new
|
||||
ActionMailer::Base.deliveries.clear
|
||||
|
||||
Contacts::SubmitInquiry::LIMIT.times do
|
||||
post public_contatti_path, params: valid_params
|
||||
expect(response).to redirect_to(public_contatti_path)
|
||||
end
|
||||
|
||||
post public_contatti_path, params: valid_params.merge(email: "altro@test.it")
|
||||
|
||||
expect(response).to have_http_status(:too_many_requests)
|
||||
expect(flash[:alert]).to eq(I18n.t("flash.contacts.throttled", locale: :it))
|
||||
expect(ActionMailer::Base.deliveries.size).to eq(Contacts::SubmitInquiry::LIMIT)
|
||||
ensure
|
||||
Rails.cache = previous
|
||||
end
|
||||
end
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package com.matchlivetv.match_live_tv.core
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class AnnouncementStore(context: Context) {
|
||||
private val prefs = context.applicationContext.getSharedPreferences("announcements", Context.MODE_PRIVATE)
|
||||
|
||||
fun dismissedIds(): Set<String> =
|
||||
prefs.getStringSet(KEY_DISMISSED, emptySet())?.toSet().orEmpty()
|
||||
|
||||
fun dismiss(id: String) {
|
||||
prefs.edit().putStringSet(KEY_DISMISSED, dismissedIds() + id).apply()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val KEY_DISMISSED = "dismissed_ids"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.matchlivetv.match_live_tv.data
|
||||
|
||||
import android.content.Context
|
||||
import com.matchlivetv.match_live_tv.core.AnnouncementStore
|
||||
import com.matchlivetv.match_live_tv.core.AppConfig
|
||||
import com.matchlivetv.match_live_tv.core.AppLocale
|
||||
import com.matchlivetv.match_live_tv.core.TokenStore
|
||||
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
|
||||
import com.matchlivetv.match_live_tv.data.api.MatchLiveApi
|
||||
import com.matchlivetv.match_live_tv.data.cable.SessionCableService
|
||||
import com.matchlivetv.match_live_tv.data.repository.AuthRepository
|
||||
@@ -86,6 +88,14 @@ class AppContainer(context: Context) {
|
||||
|
||||
val matchRepository = MatchRepository(api, tokenStore, appContext)
|
||||
|
||||
val announcementStore = AnnouncementStore(appContext)
|
||||
|
||||
suspend fun visibleAnnouncements(): List<AnnouncementDto> {
|
||||
val dismissed = announcementStore.dismissedIds()
|
||||
return runCatching { api.announcements() }.getOrElse { emptyList() }
|
||||
.filter { it.id !in dismissed }
|
||||
}
|
||||
|
||||
val sessionRepository = SessionRepository(api)
|
||||
|
||||
val scoreRepository = ScoreRepository(api)
|
||||
|
||||
@@ -414,3 +414,14 @@ data class TelemetryRequest(
|
||||
val fps: Int? = null,
|
||||
@Json(name = "thermal_state") val thermalState: String? = null,
|
||||
)
|
||||
|
||||
data class AnnouncementDto(
|
||||
val id: String,
|
||||
val kind: String,
|
||||
val severity: String = "info",
|
||||
val title: String,
|
||||
val body: String,
|
||||
val dismissible: Boolean = true,
|
||||
@Json(name = "action_url") val actionUrl: String? = null,
|
||||
@Json(name = "action_label") val actionLabel: String? = null,
|
||||
)
|
||||
|
||||
+4
@@ -8,6 +8,7 @@ import retrofit2.http.PATCH
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Part
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import okhttp3.MultipartBody
|
||||
|
||||
interface MatchLiveApi {
|
||||
@@ -38,6 +39,9 @@ interface MatchLiveApi {
|
||||
@GET("sports")
|
||||
suspend fun sports(): List<SportDto>
|
||||
|
||||
@GET("announcements")
|
||||
suspend fun announcements(@Query("platform") platform: String = "android"): List<AnnouncementDto>
|
||||
|
||||
@GET("teams")
|
||||
suspend fun teams(): List<TeamDto>
|
||||
|
||||
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.matchlivetv.match_live_tv.ui.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Campaign
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Construction
|
||||
import androidx.compose.material.icons.filled.SystemUpdate
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
|
||||
import com.matchlivetv.match_live_tv.ui.theme.MatchColors
|
||||
|
||||
@Composable
|
||||
fun AnnouncementBanners(
|
||||
announcements: List<AnnouncementDto>,
|
||||
onDismiss: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
if (announcements.isEmpty()) return
|
||||
Column(modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
announcements.forEach { item ->
|
||||
AnnouncementBanner(item = item, onDismiss = { onDismiss(item.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AnnouncementBanner(
|
||||
item: AnnouncementDto,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val accent = announcementAccent(item)
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.background(accent.copy(alpha = 0.16f), RoundedCornerShape(12.dp))
|
||||
.padding(14.dp),
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
Icon(announcementIcon(item.kind), contentDescription = null, tint = accent)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(item.title, color = accent, style = MaterialTheme.typography.titleMedium)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(item.body, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
if (item.dismissible) {
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
Icons.Filled.Close,
|
||||
contentDescription = stringResource(R.string.announcement_dismiss),
|
||||
tint = MatchColors.TextSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
val actionUrl = item.actionUrl?.takeIf { it.isNotBlank() }
|
||||
if (actionUrl != null) {
|
||||
TextButton(onClick = { runCatching { uriHandler.openUri(actionUrl) } }) {
|
||||
Text(
|
||||
item.actionLabel?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.announcement_open),
|
||||
color = accent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun announcementAccent(item: AnnouncementDto): Color = when {
|
||||
item.severity == "critical" || item.kind == "maintenance" -> MatchColors.AccentYellow
|
||||
item.kind == "update" -> MatchColors.SuccessGreen
|
||||
else -> MatchColors.PrimaryRed
|
||||
}
|
||||
|
||||
private fun announcementIcon(kind: String) = when (kind) {
|
||||
"maintenance" -> Icons.Filled.Construction
|
||||
"update" -> Icons.Filled.SystemUpdate
|
||||
else -> Icons.Filled.Campaign
|
||||
}
|
||||
+18
@@ -25,6 +25,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -52,6 +53,8 @@ import androidx.compose.ui.unit.dp
|
||||
import com.matchlivetv.match_live_tv.BuildConfig
|
||||
import com.matchlivetv.match_live_tv.R
|
||||
import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
|
||||
import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
|
||||
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
@@ -72,6 +75,7 @@ fun LoginScreen(
|
||||
var loading by remember { mutableStateOf(false) }
|
||||
var passwordVisible by remember { mutableStateOf(false) }
|
||||
var showLanguagePicker by remember { mutableStateOf(false) }
|
||||
var announcements by remember { mutableStateOf<List<AnnouncementDto>>(emptyList()) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val errCredentials = stringResource(R.string.login_error_credentials)
|
||||
@@ -98,6 +102,10 @@ fun LoginScreen(
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
announcements = container.visibleAnnouncements()
|
||||
}
|
||||
|
||||
MatchScreenScaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
@@ -142,6 +150,16 @@ fun LoginScreen(
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
MatchLiveWordmark(showSlogan = true)
|
||||
if (announcements.isNotEmpty()) {
|
||||
Spacer(Modifier.height(24.dp))
|
||||
AnnouncementBanners(
|
||||
announcements = announcements,
|
||||
onDismiss = { id ->
|
||||
container.announcementStore.dismiss(id)
|
||||
announcements = announcements.filterNot { it.id == id }
|
||||
},
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(48.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.login_submit).uppercase(),
|
||||
|
||||
+17
-1
@@ -53,6 +53,8 @@ import com.matchlivetv.match_live_tv.data.AppContainer
|
||||
import com.matchlivetv.match_live_tv.data.repository.MatchSessionLauncher
|
||||
import com.matchlivetv.match_live_tv.domain.Match
|
||||
import com.matchlivetv.match_live_tv.domain.Team
|
||||
import com.matchlivetv.match_live_tv.data.api.AnnouncementDto
|
||||
import com.matchlivetv.match_live_tv.ui.components.AnnouncementBanners
|
||||
import com.matchlivetv.match_live_tv.ui.components.LanguagePickerDialog
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchLiveWordmark
|
||||
import com.matchlivetv.match_live_tv.ui.components.MatchPrimaryButton
|
||||
@@ -86,6 +88,7 @@ fun MatchesScreen(
|
||||
var teams by remember { mutableStateOf<List<Team>>(emptyList()) }
|
||||
var activeTeam by remember { mutableStateOf<Team?>(null) }
|
||||
var matches by remember { mutableStateOf<List<Match>>(emptyList()) }
|
||||
var announcements by remember { mutableStateOf<List<AnnouncementDto>>(emptyList()) }
|
||||
|
||||
var showNewMatchSheet by remember { mutableStateOf(false) }
|
||||
var showScheduleSheet by remember { mutableStateOf(false) }
|
||||
@@ -116,6 +119,7 @@ fun MatchesScreen(
|
||||
}.onFailure {
|
||||
error = it.message ?: msgLoadError
|
||||
}
|
||||
announcements = container.visibleAnnouncements()
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
@@ -192,7 +196,18 @@ fun MatchesScreen(
|
||||
if (showLanguagePicker) {
|
||||
LanguagePickerDialog(onDismiss = { showLanguagePicker = false })
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
if (announcements.isNotEmpty()) {
|
||||
AnnouncementBanners(
|
||||
announcements = announcements,
|
||||
onDismiss = { id ->
|
||||
container.announcementStore.dismiss(id)
|
||||
announcements = announcements.filterNot { it.id == id }
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
Box(Modifier.weight(1f)) {
|
||||
PullToRefreshBox(
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = { reload(showInitialSpinner = false) },
|
||||
@@ -339,6 +354,7 @@ fun MatchesScreen(
|
||||
hostState = snackbarHostState,
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="action_cancel">Abbrechen</string>
|
||||
<string name="action_confirm">Bestätigen</string>
|
||||
<string name="action_save">Speichern</string>
|
||||
<string name="announcement_dismiss">Hinweis schließen</string>
|
||||
<string name="announcement_open">Öffnen</string>
|
||||
<string name="login_email">E-Mail</string>
|
||||
<string name="login_password">Passwort</string>
|
||||
<string name="login_submit">Anmelden</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="action_cancel">Cancel</string>
|
||||
<string name="action_confirm">Confirm</string>
|
||||
<string name="action_save">Save</string>
|
||||
<string name="announcement_dismiss">Dismiss notice</string>
|
||||
<string name="announcement_open">Open</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_submit">Log in</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="action_cancel">Cancelar</string>
|
||||
<string name="action_confirm">Confirmar</string>
|
||||
<string name="action_save">Guardar</string>
|
||||
<string name="announcement_dismiss">Cerrar aviso</string>
|
||||
<string name="announcement_open">Abrir</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Contraseña</string>
|
||||
<string name="login_submit">Acceder</string>
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string name="action_cancel">Annuler</string>
|
||||
<string name="action_confirm">Confirmer</string>
|
||||
<string name="action_save">Enregistrer</string>
|
||||
<string name="announcement_dismiss">Fermer l\'alerte</string>
|
||||
<string name="announcement_open">Ouvrir</string>
|
||||
<string name="login_email">E-mail</string>
|
||||
<string name="login_password">Mot de passe</string>
|
||||
<string name="login_submit">Connexion</string>
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
<string name="action_cancel">Annulla</string>
|
||||
<string name="action_confirm">Conferma</string>
|
||||
<string name="action_save">Salva</string>
|
||||
<string name="announcement_dismiss">Chiudi avviso</string>
|
||||
<string name="announcement_open">Apri</string>
|
||||
<string name="login_email">Email</string>
|
||||
<string name="login_password">Password</string>
|
||||
<string name="login_submit">Accedi</string>
|
||||
|
||||
@@ -108,6 +108,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Sessione scaduta. Accedi di nuovo.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Ogni partita, ogni evento, per i tuoi tifosi.",
|
||||
"announcement.dismiss": "Chiudi avviso",
|
||||
"announcement.open": "Apri",
|
||||
"broadcast.close.set.button": "CHIUDI SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Anteprima camera non pronta",
|
||||
"broadcast.error.permissions.camera.mic": "Permessi camera e microfono richiesti",
|
||||
@@ -439,6 +441,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Session expired. Please sign in again.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Every match, every event, for your fans.",
|
||||
"announcement.dismiss": "Dismiss notice",
|
||||
"announcement.open": "Open",
|
||||
"broadcast.close.set.button": "CLOSE SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Camera preview not ready",
|
||||
"broadcast.error.permissions.camera.mic": "Camera and microphone permission required",
|
||||
@@ -770,6 +774,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Session expirée. Connectez-vous à nouveau.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Chaque match, chaque événement, pour vos fans.",
|
||||
"announcement.dismiss": "Fermer l'alerte",
|
||||
"announcement.open": "Ouvrir",
|
||||
"broadcast.close.set.button": "FERMER LE SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Aperçu caméra non prêt",
|
||||
"broadcast.error.permissions.camera.mic": "Autorisations caméra et micro requises",
|
||||
@@ -1101,6 +1107,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Sitzung abgelaufen. Bitte erneut anmelden.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Jedes Spiel, jedes Event, für eure Fans.",
|
||||
"announcement.dismiss": "Hinweis schließen",
|
||||
"announcement.open": "Öffnen",
|
||||
"broadcast.close.set.button": "SATZ BEENDEN",
|
||||
"broadcast.error.camera.preview.not.ready": "Kameravorschau nicht bereit",
|
||||
"broadcast.error.permissions.camera.mic": "Kamera- und Mikrofonberechtigung erforderlich",
|
||||
@@ -1432,6 +1440,8 @@ enum L10n {
|
||||
"api.error.unauthorized": "Sesión caducada. Vuelve a iniciar sesión.",
|
||||
"app.name": "Match Live TV",
|
||||
"app.slogan": "Cada partido, cada evento, para tus aficionados.",
|
||||
"announcement.dismiss": "Cerrar aviso",
|
||||
"announcement.open": "Abrir",
|
||||
"broadcast.close.set.button": "CERRAR SET",
|
||||
"broadcast.error.camera.preview.not.ready": "Vista previa de cámara no lista",
|
||||
"broadcast.error.permissions.camera.mic": "Se requieren permisos de cámara y micrófono",
|
||||
|
||||
@@ -109,3 +109,17 @@ final class TokenStore: ObservableObject {
|
||||
SecItemDelete(query as CFDictionary)
|
||||
}
|
||||
}
|
||||
|
||||
final class AnnouncementStore {
|
||||
private let key = "mltv.dismissedAnnouncements"
|
||||
|
||||
func dismissedIds() -> Set<String> {
|
||||
Set(UserDefaults.standard.stringArray(forKey: key) ?? [])
|
||||
}
|
||||
|
||||
func dismiss(_ id: String) {
|
||||
var ids = dismissedIds()
|
||||
ids.insert(id)
|
||||
UserDefaults.standard.set(Array(ids), forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,17 @@ struct MessageResponse: Decodable {
|
||||
let message: String
|
||||
}
|
||||
|
||||
struct AnnouncementDto: Decodable, Identifiable, Equatable {
|
||||
let id: String
|
||||
let kind: String
|
||||
let severity: String
|
||||
let title: String
|
||||
let body: String
|
||||
let dismissible: Bool
|
||||
let actionUrl: String?
|
||||
let actionLabel: String?
|
||||
}
|
||||
|
||||
struct LoginResponse: Decodable {
|
||||
let user: UserDto
|
||||
let accessToken: String
|
||||
|
||||
@@ -106,6 +106,10 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
try await get("sports")
|
||||
}
|
||||
|
||||
func announcements() async throws -> [AnnouncementDto] {
|
||||
try await get("announcements", query: ["platform": "ios"])
|
||||
}
|
||||
|
||||
func teams() async throws -> [TeamDto] {
|
||||
try await get("teams")
|
||||
}
|
||||
@@ -218,8 +222,8 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
|
||||
private struct EmptyBody: Encodable {}
|
||||
|
||||
private func get<T: Decodable>(_ path: String) async throws -> T {
|
||||
try await request(path, method: "GET")
|
||||
private func get<T: Decodable>(_ path: String, query: [String: String] = [:]) async throws -> T {
|
||||
try await request(path, method: "GET", query: query)
|
||||
}
|
||||
|
||||
private func post<T: Decodable, B: Encodable>(_ path: String, body: B) async throws -> T {
|
||||
@@ -257,9 +261,17 @@ final class MatchLiveAPI: @unchecked Sendable {
|
||||
private func request<T: Decodable, B: Encodable>(
|
||||
_ path: String,
|
||||
method: String,
|
||||
body: B? = nil as EmptyBody?
|
||||
body: B? = nil as EmptyBody?,
|
||||
query: [String: String] = [:]
|
||||
) async throws -> T {
|
||||
var request = URLRequest(url: baseURL.appendingPathComponent(path))
|
||||
guard var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false) else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
if !query.isEmpty {
|
||||
components.queryItems = query.map { URLQueryItem(name: $0.key, value: $0.value) }
|
||||
}
|
||||
guard let url = components.url else { throw APIError.invalidURL }
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = method
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language")
|
||||
|
||||
@@ -12,6 +12,7 @@ final class AppContainer: ObservableObject {
|
||||
let scoreController: ScoreController
|
||||
let wizardSession = WizardSessionHolder()
|
||||
let broadcastCoordinator = LiveBroadcastCoordinator()
|
||||
let announcementStore = AnnouncementStore()
|
||||
|
||||
init() {
|
||||
let api = MatchLiveAPI()
|
||||
@@ -39,4 +40,10 @@ final class AppContainer: ObservableObject {
|
||||
OverlayLogoCache.configure(token: session.accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
func visibleAnnouncements() async -> [AnnouncementDto] {
|
||||
let dismissed = announcementStore.dismissedIds()
|
||||
let all = (try? await api.announcements()) ?? []
|
||||
return all.filter { !dismissed.contains($0.id) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ struct LoginScreen: View {
|
||||
@State private var passwordVisible = false
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var languageTick = 0
|
||||
@State private var announcements: [AnnouncementDto] = []
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold {
|
||||
@@ -30,6 +31,16 @@ struct LoginScreen: View {
|
||||
.padding(.top, 8)
|
||||
MatchLiveWordmark(showSlogan: true)
|
||||
.padding(.top, 16)
|
||||
if !announcements.isEmpty {
|
||||
AnnouncementBanners(
|
||||
announcements: announcements,
|
||||
onDismiss: { id in
|
||||
container.announcementStore.dismiss(id)
|
||||
announcements.removeAll { $0.id == id }
|
||||
}
|
||||
)
|
||||
.padding(.top, 24)
|
||||
}
|
||||
Text(L10n.t("login.submit").uppercased())
|
||||
.font(MatchTypography.headlineMedium)
|
||||
.padding(.top, 32)
|
||||
@@ -73,6 +84,9 @@ struct LoginScreen: View {
|
||||
languageTick += 1
|
||||
}
|
||||
.id(languageTick)
|
||||
.task {
|
||||
announcements = await container.visibleAnnouncements()
|
||||
}
|
||||
}
|
||||
|
||||
private func submitLogin() {
|
||||
|
||||
@@ -22,6 +22,7 @@ struct MatchesScreen: View {
|
||||
@State private var snackbar: String?
|
||||
@State private var showLanguagePicker = false
|
||||
@State private var languageTick = 0
|
||||
@State private var announcements: [AnnouncementDto] = []
|
||||
|
||||
var body: some View {
|
||||
MatchScreenScaffold(
|
||||
@@ -48,6 +49,18 @@ struct MatchesScreen: View {
|
||||
.padding(.vertical, 8)
|
||||
},
|
||||
content: {
|
||||
VStack(spacing: 0) {
|
||||
if !announcements.isEmpty {
|
||||
AnnouncementBanners(
|
||||
announcements: announcements,
|
||||
onDismiss: { id in
|
||||
container.announcementStore.dismiss(id)
|
||||
announcements.removeAll { $0.id == id }
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 8)
|
||||
}
|
||||
Group {
|
||||
if loading {
|
||||
ProgressView().tint(MatchColors.primaryRed)
|
||||
@@ -135,6 +148,7 @@ struct MatchesScreen: View {
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
)
|
||||
.task(id: refreshToken) { reload(showSpinner: refreshToken == 0) }
|
||||
.alert(L10n.t("sheet.configure.now.title"), isPresented: Binding(get: { resumeMatch != nil }, set: { if !$0 { resumeMatch = nil } })) {
|
||||
@@ -277,6 +291,7 @@ struct MatchesScreen: View {
|
||||
} catch {
|
||||
self.error = UserFacingError.message(for: error) ?? L10n.t("matches.msg.load.error")
|
||||
}
|
||||
announcements = await container.visibleAnnouncements()
|
||||
loading = false
|
||||
refreshing = false
|
||||
}
|
||||
@@ -418,6 +433,77 @@ private struct MatchListCard: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct AnnouncementBanners: View {
|
||||
let announcements: [AnnouncementDto]
|
||||
let onDismiss: (String) -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(announcements) { item in
|
||||
AnnouncementBanner(item: item, onDismiss: { onDismiss(item.id) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AnnouncementBanner: View {
|
||||
let item: AnnouncementDto
|
||||
let onDismiss: () -> Void
|
||||
@Environment(\.openURL) private var openURL
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: iconName)
|
||||
.foregroundStyle(accent)
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(MatchTypography.titleMedium)
|
||||
.foregroundStyle(accent)
|
||||
Text(item.body)
|
||||
.font(MatchTypography.bodyMedium)
|
||||
}
|
||||
Spacer(minLength: 8)
|
||||
if item.dismissible {
|
||||
Button(action: onDismiss) {
|
||||
Image(systemName: "xmark")
|
||||
.foregroundStyle(MatchColors.textSecondary)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(L10n.t("announcement.dismiss"))
|
||||
}
|
||||
}
|
||||
if let urlString = item.actionUrl, let url = URL(string: urlString), !urlString.isEmpty {
|
||||
Button(item.actionLabel?.isEmpty == false ? item.actionLabel! : L10n.t("announcement.open")) {
|
||||
openURL(url)
|
||||
}
|
||||
.font(MatchTypography.labelLarge)
|
||||
.foregroundStyle(accent)
|
||||
}
|
||||
}
|
||||
.padding(14)
|
||||
.background(accent.opacity(0.16), in: RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
|
||||
private var accent: Color {
|
||||
if item.severity == "critical" || item.kind == "maintenance" {
|
||||
return MatchColors.accentYellow
|
||||
}
|
||||
if item.kind == "update" {
|
||||
return MatchColors.successGreen
|
||||
}
|
||||
return MatchColors.primaryRed
|
||||
}
|
||||
|
||||
private var iconName: String {
|
||||
switch item.kind {
|
||||
case "maintenance": return "wrench.and.screwdriver.fill"
|
||||
case "update": return "arrow.down.app.fill"
|
||||
default: return "megaphone.fill"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct NewMatchSheet: View {
|
||||
let onSchedule: () -> Void
|
||||
let onQuickStart: () -> Void
|
||||
|
||||
Reference in New Issue
Block a user