diff --git a/backend/app/controllers/api/v1/accounts_controller.rb b/backend/app/controllers/api/v1/accounts_controller.rb index d903ed3..1ef42cd 100644 --- a/backend/app/controllers/api/v1/accounts_controller.rb +++ b/backend/app/controllers/api/v1/accounts_controller.rb @@ -8,7 +8,7 @@ module Api def update name = params[:name].to_s.strip if name.blank? - return render json: { error: "Name is required" }, status: :unprocessable_entity + return render json: { error: I18n.t("flash.accounts.name_required") }, status: :unprocessable_entity end if current_user.update(name: name) @@ -30,7 +30,7 @@ module Api return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity end - render json: { message: "Password updated" } + render json: { message: I18n.t("flash.accounts.password_updated") } end private @@ -46,14 +46,13 @@ module Api def password_error_message(code) case code - when :current_incorrect then "Current password is incorrect" - when :too_short then "Password must be at least 8 characters" - when :too_long then "Password cannot exceed 72 characters" - when :too_weak - "Password must include at least 3 of: lowercase, uppercase, number, symbol" - when :same_as_current then "New password must be different from the current password" - when :mismatch then "Passwords do not match" - else "Unable to update password" + when :current_incorrect then I18n.t("flash.accounts.password_current_incorrect") + when :too_short then I18n.t("flash.accounts.password_too_short") + when :too_long then I18n.t("flash.accounts.password_too_long") + when :too_weak then I18n.t("flash.accounts.password_too_weak") + when :same_as_current then I18n.t("flash.accounts.password_same_as_current") + when :mismatch then I18n.t("flash.accounts.password_mismatch") + else I18n.t("flash.accounts.password_update_failed") end end end diff --git a/backend/app/controllers/api/v1/auth_controller.rb b/backend/app/controllers/api/v1/auth_controller.rb index f0b7439..7f9b886 100644 --- a/backend/app/controllers/api/v1/auth_controller.rb +++ b/backend/app/controllers/api/v1/auth_controller.rb @@ -22,18 +22,18 @@ module Api if user&.authenticate(params[:password]) render json: token_response(user), status: :ok else - render json: { error: "Invalid credentials" }, status: :unauthorized + render json: { error: I18n.t("flash.sessions.invalid_credentials") }, status: :unauthorized end end def logout - render json: { message: "Logged out" } + render json: { message: I18n.t("flash.sessions.logged_out") } end def refresh payload = JsonWebToken.decode(params[:refresh_token] || bearer_token) user = User.find_by(id: payload&.dig(:user_id)) - return render json: { error: "Invalid token" }, status: :unauthorized unless user + return render json: { error: I18n.t("flash.sessions.invalid_token") }, status: :unauthorized unless user render json: token_response(user) end @@ -45,7 +45,7 @@ module Api def forgot_password Users::RequestPasswordReset.call(email: params[:email]) render json: { - message: "If the email is registered, you will receive a password reset link shortly." + message: I18n.t("flash.password_resets.email_sent") } end diff --git a/backend/app/controllers/application_controller.rb b/backend/app/controllers/application_controller.rb index 029725c..94f9d34 100644 --- a/backend/app/controllers/application_controller.rb +++ b/backend/app/controllers/application_controller.rb @@ -1,17 +1,25 @@ class ApplicationController < ActionController::API include ActionController::HttpAuthentication::Token::ControllerMethods + before_action :set_api_locale before_action :authenticate_request! attr_reader :current_user private + def set_api_locale + explicit = LocaleResolver.normalize(request.headers["X-Locale"]) + I18n.locale = explicit || + LocaleResolver.from_accept_language(request.headers["Accept-Language"]) || + I18n.default_locale + end + def authenticate_request! token = bearer_token payload = JsonWebToken.decode(token) @current_user = User.find_by(id: payload[:user_id]) if payload - render json: { error: "Unauthorized" }, status: :unauthorized unless @current_user + render json: { error: I18n.t("flash.sessions.unauthorized") }, status: :unauthorized unless @current_user end def bearer_token diff --git a/backend/app/controllers/public/accounts_controller.rb b/backend/app/controllers/public/accounts_controller.rb index 5677824..f327719 100644 --- a/backend/app/controllers/public/accounts_controller.rb +++ b/backend/app/controllers/public/accounts_controller.rb @@ -29,8 +29,8 @@ module Public ) unless result.ok? - flash.now[:alert] = t("flash.accounts.password_#{result.error}") - return render :show, status: :unprocessable_entity + redirect_to public_account_path, alert: t("flash.accounts.password_#{result.error}") + return end redirect_to public_account_path, notice: t("flash.accounts.password_updated") diff --git a/backend/app/controllers/public/password_resets_controller.rb b/backend/app/controllers/public/password_resets_controller.rb index 28d62e7..82948dd 100644 --- a/backend/app/controllers/public/password_resets_controller.rb +++ b/backend/app/controllers/public/password_resets_controller.rb @@ -13,7 +13,7 @@ module Public def edit @user = User.find_by_password_reset_token(params[:token]) if @user.nil? || @user.password_reset_expired? - redirect_to new_public_password_reset_path, + redirect_to public_password_forgot_path, alert: t("flash.password_resets.invalid_or_expired_link") return end @@ -23,7 +23,7 @@ module Public def update @user = User.find_by_password_reset_token(params[:token]) if @user.nil? || @user.password_reset_expired? - redirect_to new_public_password_reset_path, + redirect_to public_password_forgot_path, alert: t("flash.password_resets.invalid_or_expired_link") return end diff --git a/backend/app/views/layouts/marketing.html.erb b/backend/app/views/layouts/marketing.html.erb index f1dbedc..0561bfa 100644 --- a/backend/app/views/layouts/marketing.html.erb +++ b/backend/app/views/layouts/marketing.html.erb @@ -8,7 +8,7 @@ <%= render "shared/meta_tags" %> <%= yield :head %> - + data-ga-id="<%= MatchLiveTv.google_analytics_measurement_id %>"<% end %>> <%= render "shared/cookie_banner" %> diff --git a/backend/app/views/layouts/marketing_live.html.erb b/backend/app/views/layouts/marketing_live.html.erb index af3b302..048d895 100644 --- a/backend/app/views/layouts/marketing_live.html.erb +++ b/backend/app/views/layouts/marketing_live.html.erb @@ -6,7 +6,7 @@ <%= content_for?(:title) ? yield(:title) : "Match Live TV" %> <%= render "shared/meta_tags" %> - + <%= yield :head %> diff --git a/backend/app/views/public/invitations/show.html.erb b/backend/app/views/public/invitations/show.html.erb index bb232e3..b07d377 100644 --- a/backend/app/views/public/invitations/show.html.erb +++ b/backend/app/views/public/invitations/show.html.erb @@ -2,25 +2,27 @@ <% content_for :meta_description, t("auth.invitation.meta_description") %> <% content_for :robots, "noindex, nofollow" %> -
-

<%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %>

-

<%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %>

-

- <%= raw t("auth.invitation.instructions_html", email: @invitation.email) %> -

- <% if logged_in? %> - <%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> - <% else %> -

<%= raw t( - "auth.invitation.login_or_signup_html", - login_link: link_to(t("auth.invitation.login_link"), public_login_path), - signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path), - email: @invitation.email - ) %>

- <%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> - <% end %> -

- <%= t("auth.invitation.mobile_app_label") %> - <%= t("auth.invitation.open_in_app") %> -

-
+
+
+

<%= raw t("auth.invitation.title_html", team_name: @invitation.team.name) %>

+

<%= raw t("auth.invitation.role_notice_html", email: @invitation.email) %>

+

+ <%= raw t("auth.invitation.instructions_html", email: @invitation.email) %> +

+ <% if logged_in? %> + <%= button_to t("auth.invitation.accept"), public_invitation_path(token: @token), method: :post, class: "btn btn-primary" %> + <% else %> +

<%= raw t( + "auth.invitation.login_or_signup_html", + login_link: link_to(t("auth.invitation.login_link"), public_login_path), + signup_link: link_to(t("auth.invitation.signup_link"), public_signup_path), + email: @invitation.email + ) %>

+ <%= button_to t("auth.invitation.accept_if_logged_in"), public_invitation_path(token: @token), method: :post, class: "btn btn-secondary" %> + <% end %> +

+ <%= t("auth.invitation.mobile_app_label") %> + <%= t("auth.invitation.open_in_app") %> +

+
+
diff --git a/backend/app/views/public/pages/cookies.html.erb b/backend/app/views/public/pages/cookies.html.erb index 4154906..b3d0634 100644 --- a/backend/app/views/public/pages/cookies.html.erb +++ b/backend/app/views/public/pages/cookies.html.erb @@ -46,7 +46,8 @@

<%= t("legal.cookies.s4_1_title") %>

<%= t("legal.cookies.s4_1_intro") %>

- +
+
@@ -64,7 +65,8 @@ - + +

<%= t("legal.cookies.s4_2_title") %>

@@ -75,7 +77,8 @@ <% else %>

<%= t("legal.cookies.s4_2_inactive") %>

<% end %> - +
+
@@ -99,7 +102,8 @@ - + +

<%= raw t( "legal.cookies.s4_2_p2_html", diff --git a/backend/app/views/public/pages/pricing.html.erb b/backend/app/views/public/pages/pricing.html.erb index 8d4a774..329362e 100644 --- a/backend/app/views/public/pages/pricing.html.erb +++ b/backend/app/views/public/pages/pricing.html.erb @@ -22,20 +22,22 @@ <%= render "shared/plan_cards" %> - - - - - - - - - - - - - -
FreePremium LightPremium Full
<%= t("pages.pricing.table_staff") %>15<%= t("pages.pricing.table_unlimited") %>
<%= t("pages.pricing.table_matches") %>1310
<%= t("pages.pricing.table_live_mltv") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_youtube") %><%= t("pages.pricing.table_no") %>Match Live TV<%= t("pages.pricing.table_youtube_club") %>
<%= t("pages.pricing.table_replay") %><%= t("pages.pricing.table_no") %><%= t("pages.plans.replay_days", count: 30) %><%= t("pages.plans.replay_days", count: 90) %>
<%= t("pages.pricing.table_download") %><%= t("pages.pricing.table_no") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_price") %><%= t("pages.pricing.table_price_free") %><%= raw t("pages.pricing.table_price_light_html") %><%= raw t("pages.pricing.table_price_full_html") %>
+

+ + + + + + + + + + + + + +
FreePremium LightPremium Full
<%= t("pages.pricing.table_staff") %>15<%= t("pages.pricing.table_unlimited") %>
<%= t("pages.pricing.table_matches") %>1310
<%= t("pages.pricing.table_live_mltv") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_youtube") %><%= t("pages.pricing.table_no") %>Match Live TV<%= t("pages.pricing.table_youtube_club") %>
<%= t("pages.pricing.table_replay") %><%= t("pages.pricing.table_no") %><%= t("pages.plans.replay_days", count: 30) %><%= t("pages.plans.replay_days", count: 90) %>
<%= t("pages.pricing.table_download") %><%= t("pages.pricing.table_no") %><%= t("pages.pricing.table_yes") %><%= t("pages.pricing.table_yes") %>
<%= t("pages.pricing.table_price") %><%= t("pages.pricing.table_price_free") %><%= raw t("pages.pricing.table_price_light_html") %><%= raw t("pages.pricing.table_price_full_html") %>
+

<%= t("pages.pricing.different_title") %>

diff --git a/backend/app/views/public/pages/privacy.html.erb b/backend/app/views/public/pages/privacy.html.erb index 20fbde4..9884044 100644 --- a/backend/app/views/public/pages/privacy.html.erb +++ b/backend/app/views/public/pages/privacy.html.erb @@ -77,7 +77,8 @@

<%= t("legal.privacy.s5_title") %>

- +
+
@@ -107,7 +108,8 @@ - + +
diff --git a/backend/app/views/shared/_billing_documents.html.erb b/backend/app/views/shared/_billing_documents.html.erb index fd7d792..2081dfb 100644 --- a/backend/app/views/shared/_billing_documents.html.erb +++ b/backend/app/views/shared/_billing_documents.html.erb @@ -20,6 +20,7 @@

<%= t("billing.documents.table_heading") %>

<% if payments.any? %> +
@@ -60,6 +61,7 @@ <% end %>
+
<% else %>

<%= t("billing.documents.no_payments") %>

<% end %> diff --git a/backend/app/views/shared/_language_switcher.html.erb b/backend/app/views/shared/_language_switcher.html.erb index ba8b5df..3594d05 100644 --- a/backend/app/views/shared/_language_switcher.html.erb +++ b/backend/app/views/shared/_language_switcher.html.erb @@ -58,10 +58,18 @@ } toggle.addEventListener("click", function (e) { + e.preventDefault(); e.stopPropagation(); setOpen(menu.hidden); }); + // Keep parent mobile nav open while interacting with the switcher UI. + root.addEventListener("click", function (e) { + if (e.target.closest(".lang-switcher__toggle")) { + e.stopPropagation(); + } + }); + document.addEventListener("click", function (e) { if (!root.contains(e.target)) setOpen(false); }); diff --git a/backend/app/views/shared/_marketing_nav.html.erb b/backend/app/views/shared/_marketing_nav.html.erb index 1a34c10..3ad5ecb 100644 --- a/backend/app/views/shared/_marketing_nav.html.erb +++ b/backend/app/views/shared/_marketing_nav.html.erb @@ -19,6 +19,15 @@ @@ -78,8 +84,16 @@ if (backdrop) backdrop.addEventListener("click", closeMenu); + function shouldCloseNavOnControl(el) { + // Language toggle must keep the mobile menu open; only a locale choice may close it. + if (!el.closest("[data-lang-switcher]")) return true; + return el.classList.contains("lang-switcher__option"); + } + nav.querySelectorAll("a, button").forEach(function (el) { - el.addEventListener("click", closeMenu); + el.addEventListener("click", function () { + if (shouldCloseNavOnControl(el)) closeMenu(); + }); }); window.addEventListener("resize", function () { diff --git a/backend/config/locales/app.de.yml b/backend/config/locales/app.de.yml index 4e6f62c..63e5db2 100644 --- a/backend/config/locales/app.de.yml +++ b/backend/config/locales/app.de.yml @@ -2,19 +2,24 @@ de: password_policy: hint: "Mindestens 8 Zeichen, mit mindestens 3 aus: Kleinbuchstaben, Großbuchstaben, Zahlen und Symbolen." activerecord: + attributes: + user: + password: Passwort + admin_account: + password: Passwort errors: models: user: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + 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" admin_account: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + 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" club: back_to_club: "← Verein" @@ -617,6 +622,8 @@ de: welcome_back: Willkommen zurück! invalid_credentials: E-Mail oder Passwort ungültig logged_out: Abgemeldet + unauthorized: Nicht autorisiert + invalid_token: Ungültiges Token password_resets: email_sent: Wenn die E-Mail registriert ist, erhältst du in Kürze einen Link zum Zurücksetzen des Passworts. invalid_or_expired_link: Link ungültig oder abgelaufen. Fordere ein neues Zurücksetzen des Passworts an. @@ -637,6 +644,7 @@ 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. + password_update_failed: Passwort konnte nicht aktualisiert werden replay: download_unavailable: Download nicht verfügbar not_available: Replay nicht verfügbar diff --git a/backend/config/locales/app.en.yml b/backend/config/locales/app.en.yml index e91282e..1150d77 100644 --- a/backend/config/locales/app.en.yml +++ b/backend/config/locales/app.en.yml @@ -617,6 +617,8 @@ en: welcome_back: Welcome back! invalid_credentials: Invalid email or password logged_out: Logged out + unauthorized: Unauthorized + invalid_token: Invalid token password_resets: email_sent: If the email is registered, you'll receive a password reset link shortly. invalid_or_expired_link: Invalid or expired link. Request a new password reset. @@ -637,6 +639,7 @@ en: password_same_as_current: New password must be different from the current password password_mismatch: Passwords do not match password_updated: Password updated. + password_update_failed: Unable to update password replay: download_unavailable: Download not available not_available: Replay not available diff --git a/backend/config/locales/app.es.yml b/backend/config/locales/app.es.yml index c1ff4ae..08c1647 100644 --- a/backend/config/locales/app.es.yml +++ b/backend/config/locales/app.es.yml @@ -2,19 +2,24 @@ es: password_policy: hint: "Mínimo 8 caracteres, con al menos 3 entre: minúsculas, mayúsculas, números y símbolos." activerecord: + attributes: + user: + password: Contraseña + admin_account: + password: Contraseña errors: models: user: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + 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" admin_account: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + 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" club: back_to_club: "← Club" @@ -617,6 +622,8 @@ es: welcome_back: "¡Bienvenido de nuevo!" invalid_credentials: Correo o contraseña no válidos logged_out: Sesión cerrada + unauthorized: No autorizado + invalid_token: Token no válido password_resets: email_sent: Si el correo está registrado, recibirás en breve un enlace para restablecer la contraseña. invalid_or_expired_link: Enlace no válido o caducado. Solicita un nuevo restablecimiento de contraseña. @@ -637,6 +644,7 @@ 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. + password_update_failed: No se pudo actualizar la contraseña replay: download_unavailable: Descarga no disponible not_available: Repetición no disponible diff --git a/backend/config/locales/app.fr.yml b/backend/config/locales/app.fr.yml index d7fd649..60f7f8c 100644 --- a/backend/config/locales/app.fr.yml +++ b/backend/config/locales/app.fr.yml @@ -2,19 +2,24 @@ fr: password_policy: hint: "Au moins 8 caractères, avec au moins 3 parmi : minuscules, majuscules, chiffres et symboles." activerecord: + attributes: + user: + password: Mot de passe + admin_account: + password: Mot de passe errors: models: user: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + 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" admin_account: attributes: password: - too_short: "is too short (minimum is %{count} characters)" - too_long: "is too long (maximum is %{count} characters)" + 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" club: back_to_club: "← Club" @@ -617,6 +622,8 @@ fr: welcome_back: Bon retour ! invalid_credentials: E-mail ou mot de passe invalide logged_out: Déconnecté + unauthorized: Non autorisé + invalid_token: Jeton invalide password_resets: email_sent: Si l'e-mail est enregistré, tu recevras bientôt un lien pour réinitialiser le mot de passe. invalid_or_expired_link: Lien invalide ou expiré. Demande une nouvelle réinitialisation du mot de passe. @@ -637,6 +644,7 @@ 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. + password_update_failed: Impossible de mettre à jour le mot de passe replay: download_unavailable: Téléchargement non disponible not_available: Replay non disponible diff --git a/backend/config/locales/app.it.yml b/backend/config/locales/app.it.yml index 341e9a1..7cbb6bf 100644 --- a/backend/config/locales/app.it.yml +++ b/backend/config/locales/app.it.yml @@ -617,6 +617,8 @@ it: welcome_back: Bentornato! invalid_credentials: Email o password non validi logged_out: Disconnesso + unauthorized: Non autorizzato + invalid_token: Token non valido password_resets: email_sent: Se l'email è registrata, riceverai a breve un link per reimpostare la password. invalid_or_expired_link: Link non valido o scaduto. Richiedi un nuovo reset password. @@ -642,6 +644,7 @@ 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. + password_update_failed: Impossibile aggiornare la password replay: download_unavailable: Download non disponibile not_available: Replay non disponibile diff --git a/backend/config/routes.rb b/backend/config/routes.rb index dd5d8cf..5b37e3a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -169,6 +169,7 @@ Rails.application.routes.draw do patch "password/reset", to: "password_resets#update" get "account", to: "accounts#show", as: :account patch "account", to: "accounts#update" + get "account/password", to: redirect("/account"), as: nil patch "account/password", to: "accounts#update_password", as: :account_password get "clubs/new", to: "clubs#new", as: :new_club post "clubs", to: "clubs#create" diff --git a/backend/public/marketing.css b/backend/public/marketing.css index bd440db..9a80bcb 100644 --- a/backend/public/marketing.css +++ b/backend/public/marketing.css @@ -199,6 +199,17 @@ body.nav-menu-open { overflow: hidden; } body.nav-menu-open .site-chrome { z-index: 1300; } + /* Keep the close control above the full-screen sheet; hide duplicate mast brand. */ + body.nav-menu-open .site-masthead { + z-index: 1320; + background: transparent; + border-bottom-color: transparent; + backdrop-filter: none; + } + body.nav-menu-open .mast-brand { + visibility: hidden; + pointer-events: none; + } .nav-backdrop { display: block; position: fixed; @@ -236,16 +247,50 @@ body.nav-menu-open { overflow: hidden; } .nav-panel { flex: 1; flex-direction: column; + flex-wrap: nowrap; align-items: stretch; gap: 0; width: 100%; max-width: none; + min-height: 0; margin: 0; - padding: 72px 24px 32px; - padding-top: calc(72px + env(safe-area-inset-top, 0px)); + padding: 16px 24px 32px; + padding-top: calc(16px + env(safe-area-inset-top, 0px)); padding-bottom: calc(32px + env(safe-area-inset-bottom, 0px)); + overflow-x: hidden; overflow-y: auto; } + .nav-mobile-head { + order: -1; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + width: 100%; + min-height: 44px; + margin: 0 0 8px; + padding: 0 52px 16px 0; /* room for the close (X) control on the right */ + border-bottom: 1px solid #252530; + flex-shrink: 0; + } + .nav-mobile-brand { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; + text-decoration: none; + line-height: 1; + } + .nav-mobile-brand:hover { text-decoration: none; opacity: 0.92; } + .nav-mobile-brand .brand { font-size: 1.05rem; } + .nav-mobile-brand-logo { + display: block; + width: 40px; + height: 40px; + border-radius: 8px; + object-fit: contain; + flex-shrink: 0; + } .nav-panel > a, .nav-panel .nav-link-item { display: block; @@ -263,11 +308,13 @@ body.nav-menu-open { overflow: hidden; } } .nav-actions { flex-direction: column; + flex-wrap: nowrap; align-items: stretch; - gap: 14px; - margin-top: 24px; - padding: 24px 0 0; - border-top: 1px solid #252530; + gap: 0; + margin-top: 8px; + padding: 0; + border-top: none; + width: 100%; } .nav-actions .nav-link-item { display: block; @@ -289,10 +336,14 @@ body.nav-menu-open { overflow: hidden; } border-radius: 10px; } .nav-lang { - margin-left: 0; - margin-top: 4px; + margin: 0; justify-content: flex-end; - width: 100%; + width: auto; + flex-shrink: 0; + } + .nav-lang .lang-switcher__menu { + /* Keep the list inside the open mobile sheet */ + z-index: 1320; } } @@ -342,14 +393,22 @@ body.nav-menu-open { overflow: hidden; } flex-wrap: nowrap; gap: 8px 20px; } + .nav-mobile-head { + display: contents; + } + .nav-mobile-brand { + display: none; + } + .nav-lang { + order: 2; + margin-left: 2px; + } .nav-actions { + order: 1; flex-wrap: nowrap; gap: 8px 12px; margin-left: auto; } - .nav-lang { - margin-left: 2px; - } .nav-backdrop { display: none !important; } } .btn { display: inline-block; padding: 10px 18px; border-radius: 8px; font-weight: 700; font-size: 0.9rem; border: none; cursor: pointer; text-decoration: none; } @@ -1230,7 +1289,22 @@ body.nav-menu-open { overflow: hidden; } .hero-split .hero-cta { flex-direction: column; } .hero-split .hero-cta .btn { width: 100%; text-align: center; } } -.section { padding: 40px 0; } +.section { + padding-top: 40px; + padding-bottom: 40px; +} +/* Keep horizontal inset when .section shares a node with .wrap (padding shorthand must not win). */ +.section.wrap { + padding-left: 20px; + padding-right: 20px; +} +.table-scroll { + width: 100%; + max-width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + margin-top: 20px; +} .section h2 { font-size: 1.6rem; margin: 0 0 20px; text-align: center; } .steps { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; } .step { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 22px; } @@ -1363,7 +1437,7 @@ body.nav-menu-open { overflow: hidden; } .site-footer__legal { flex: 1 1 100%; margin-top: 4px; } .site-footer__legal p { margin: 0 0 6px; line-height: 1.45; } .site-footer__legal p:last-child { margin-bottom: 0; } -.compare-table { width: 100%; border-collapse: collapse; margin-top: 20px; font-size: 0.9rem; } +.compare-table { width: 100%; min-width: 520px; border-collapse: collapse; margin-top: 0; font-size: 0.9rem; } .compare-table th, .compare-table td { padding: 10px 12px; border-bottom: 1px solid #2a2a36; text-align: left; } .compare-table th { color: #aaa; font-weight: 600; } .billing-documents h2 { margin-top: 0; } @@ -1372,11 +1446,14 @@ body.nav-menu-open { overflow: hidden; } .card { background: #14141c; border: 1px solid #2a2a36; border-radius: 12px; padding: 20px; margin-bottom: 16px; } .seo-prose { max-width: 720px; margin: 0 auto; color: #bbb; line-height: 1.65; } -.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; } +.seo-prose h2 { color: #fff; font-size: 1.25rem; margin: 28px 0 12px; text-align: left; } .seo-prose h2:first-child { margin-top: 0; } .seo-prose p { margin: 0 0 14px; } .seo-prose ul { margin: 0 0 16px; padding-left: 1.25rem; } .seo-prose a { color: #e53935; } +@media (max-width: 899px) { + .seo-prose h2 { font-size: 1.15rem; line-height: 1.35; } +} .seo-page .seo-lead { color: #aaa; max-width: 640px; line-height: 1.55; margin-bottom: 28px; } .faq-list { max-width: 720px; margin: 0 auto; } .faq-item { @@ -1412,7 +1489,8 @@ body.nav-menu-open { overflow: hidden; } .legal-doc a { color: #e53935; } .legal-meta { color: #888; font-size: 0.88rem; margin-bottom: 24px; } .legal-back { margin-top: 32px; } -.legal-table { width: 100%; border-collapse: collapse; margin: 12px 0 16px; font-size: 0.88rem; } +.legal-doc .table-scroll { margin: 12px 0 16px; } +.legal-table { width: 100%; min-width: 560px; border-collapse: collapse; margin: 0; font-size: 0.88rem; } .legal-table th, .legal-table td { border: 1px solid #2a2a36; padding: 10px 12px; text-align: left; vertical-align: top; } .legal-table th { background: #14141c; color: #ccc; } .legal-accept { @@ -1447,6 +1525,19 @@ body.nav-menu-open { overflow: hidden; } .auth-forgot a { color: #e53935; } table.data { width: 100%; border-collapse: collapse; } table.data th, table.data td { padding: 8px; border-bottom: 1px solid #2a2a36; text-align: left; } +/* Wide roster/match tables: scroll inside the card instead of expanding the page. */ +@media (max-width: 899px) { + .card:has(table.data), + .team-streaming-staff:has(table.data), + .billing-documents:has(table.data) { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + max-width: 100%; + } + table.data { + min-width: 520px; + } +} input, select { width: 100%; padding: 12px 14px; diff --git a/backend/spec/requests/account_spec.rb b/backend/spec/requests/account_spec.rb index 3a87e8c..2dc7742 100644 --- a/backend/spec/requests/account_spec.rb +++ b/backend/spec/requests/account_spec.rb @@ -68,7 +68,7 @@ RSpec.describe "Account API", type: :request do password: "newpass123", password_confirmation: "newpass123" }, - headers: auth_headers + headers: auth_headers.merge("Accept-Language" => "en") expect(response).to have_http_status(:unprocessable_entity) expect(JSON.parse(response.body)["error"]).to match(/3 of/i) expect(user.reload.authenticate("Password123")).to be_truthy @@ -81,14 +81,26 @@ RSpec.describe "Account API", type: :request do password: "Password123", password_confirmation: "Password123" }, - headers: auth_headers + headers: auth_headers.merge("Accept-Language" => "en") expect(response).to have_http_status(:unprocessable_entity) expect(JSON.parse(response.body)["error"]).to match(/different/i) expect(user.reload.authenticate("Password123")).to be_truthy end + + it "localizes password errors from Accept-Language" do + patch "/api/v1/account/password", + params: { + current_password: "Password123", + password: "Password123", + password_confirmation: "Password123" + }, + headers: auth_headers.merge("Accept-Language" => "it") + expect(response).to have_http_status(:unprocessable_entity) + expect(JSON.parse(response.body)["error"]).to eq("La nuova password deve essere diversa da quella attuale") + end end - describe "POST /api/v1/auth/password/forgot" do + describe "POST /api/v1/auth/password/forgot" do it "always returns ok and sends mail when the user exists" do expect { post "/api/v1/auth/password/forgot", params: { email: user.email } @@ -103,5 +115,13 @@ RSpec.describe "Account API", type: :request do }.not_to change { ActionMailer::Base.deliveries.size } expect(response).to have_http_status(:ok) end + + it "localizes the response message from Accept-Language" do + post "/api/v1/auth/password/forgot", + params: { email: user.email }, + headers: { "Accept-Language" => "fr" } + expect(response).to have_http_status(:ok) + expect(JSON.parse(response.body)["message"]).to include("réinitialiser") + end end end diff --git a/docs/IOS_PASSWORD_POLICY_ALIGN.md b/docs/IOS_PASSWORD_POLICY_ALIGN.md new file mode 100644 index 0000000..d583452 --- /dev/null +++ b/docs/IOS_PASSWORD_POLICY_ALIGN.md @@ -0,0 +1,136 @@ +# iOS — allineamento password policy + errori API localizzati + +Documento operativo per **Cursor su Mac**: allineare l’app iOS a backend/Android dopo il ramo `feature/password-policy` (merge su `main`). + +**Aggiornato:** 2026-08-08 +**Android di riferimento (testato su emulatore → API locale):** `2.0.10-native` (`versionCode` **31**) +**iOS oggi:** marketing `2.0.5` / build `26` +**API produzione:** `https://www.matchlivetv.it` + +--- + +## Contesto (già in produzione backend dopo questo rilascio) + +### Policy password (server) + +Definita in `backend/app/models/concerns/password_complexity.rb`: + +- minimo **8** caratteri, massimo **72** byte (bcrypt) +- almeno **3 classi su 4**: minuscole, maiuscole, numeri, simboli +- in **cambio password** e **reset**: la nuova password **non** può coincidere con quella attuale + +Validazione ActiveModel su `User` / `AdminAccount`. Endpoint che la applicano: + +| Endpoint | Note | +|----------|------| +| `PATCH /api/v1/account/password` | App native + stessi codici errore | +| `PATCH /account/password` (web) | Flash I18n | +| reset password pubblico | Stesse regole | +| registrazione (`auth/register`) | Validazione modello | + +### Errori API localizzati + +`ApplicationController#set_api_locale` legge, in ordine: + +1. header `X-Locale` +2. `Accept-Language` +3. `I18n.default_locale` (fallback; **retrocompatibile** se l’app vecchia non manda nulla) + +Risposta invariata: `{ "error": "" }` (o `{ "message": "..." }` su alcuni successi). + +Chiavi rilevanti (`backend/config/locales/app.{it,en,fr,de,es}.yml`): + +- `flash.accounts.password_too_short` +- `flash.accounts.password_too_long` +- `flash.accounts.password_too_weak` +- `flash.accounts.password_same_as_current` +- `flash.accounts.password_current_incorrect` +- `flash.accounts.password_mismatch` +- `flash.accounts.password_updated` / `name_required` +- analoghi in `flash.password_resets.*` e `flash.sessions.*` + +--- + +## Stato iOS vs Android (dopo il merge di questo branch) + +| Area | Android 2.0.10 | iOS (repo dopo merge) | Da fare su Mac | +|------|----------------|------------------------|----------------| +| Header `Accept-Language` su tutte le request API | `AppContainer` OkHttp interceptor | `MatchLiveAPI.request` + `multipartPatch` | **Verificare** in debug che parta su login/account/password; se manca su altri helper HTTP, allineare | +| Hint UI nuova password | `account_new_password` in `values*` (5 lingue) | `account.new.password` in `AppLanguage.swift` (già allineato al testo policy) | OK — rivedi solo se cambi copy | +| Schermata Account | Mostra `error` dal body HTTP | `AccountScreen` + `APIError` estrae `error`/`message` | **Test manuale** (vedi sotto) | +| Parsing errori API | Regex su `"error"` | `APIError.friendlyHttpMessage` | OK | +| Client-side pre-check complessità | No (si affida al server) | No | Opzionale: stessa regola di `PasswordComplexity` prima della PATCH | +| Versione App Store | `2.0.10-native` / 31 | `2.0.5` / 26 | **Bump** marketing + build prima del submit TestFlight/App Store | +| Signup in-app | Non è il flusso principale | Nessuna UI register dedicata | N/A (signup resta web) | + +### File già toccati / da tenere + +``` +native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # Accept-Language +native/ios/MatchLiveTv/Core/AppLanguage.swift # hint account.new.password (5 lingue) +native/ios/MatchLiveTv/UI/Account/AccountScreen.swift +native/ios/MatchLiveTv/Core/UserFacingError.swift +native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift # enum APIError +``` + +Riferimento Android: + +``` +native/android/.../data/AppContainer.kt +native/android/.../ui/account/AccountScreen.kt +native/android/app/src/main/res/values*/strings.xml # account_new_password +``` + +--- + +## Checklist operativa su Mac + +1. **Pull** `main` (dopo merge/push da Linux). +2. Apri `native/ios/MatchLiveTv.xcodeproj`. +3. Build debug con `API_BASE_URL=https://www.matchlivetv.it` (o locale se hai tunnel). +4. **Test Account** (credenziali demo se disponibili, altrimenti account reale di test): + - password debole (`password`) → messaggio *too_weak* nella lingua UI + - password = corrente → *same_as_current* + - conferma diversa → *mismatch* + - password valida nuova → successo; login successivo ok +5. Cambia lingua app (globo) e ripeti un errore: il testo API deve seguire `Accept-Language` / lingua scelta. +6. **Bump versione** allineata ad Android se rilasci store: es. marketing `2.0.10`, build `≥ 31`. +7. (Opzionale) Unit test Swift che replica `PasswordComplexity.strong_enough?` se aggiungi validazione client. + +### Comandi utili + +```bash +# Clone/pull +git checkout main && git pull + +# Release iOS (script repo) +./scripts/build_ios_release.sh +# oppure API staging: +# API_BASE_URL=https://www.matchlivetv.it ./scripts/build_ios_release.sh +``` + +### Cosa non serve rifare su iOS + +- Fix CSS/menu mobile web, tabelle scroll, logo nel drawer: **solo sito**. +- Redirect `GET /account/password` → `/account`: **solo web**. +- Seed password / concern Rails: già lato server. + +--- + +## Retrocompatibilità (per release iOS) + +- App iOS **vecchie** (senza `Accept-Language`): continuano a funzionare; messaggi API nella locale default del server. +- Login con password già esistenti: invariato. +- Solo **impostazione/cambio** password debole viene rifiutato (stesso JSON `error`). + +--- + +## Criterio “fatto” + +- [ ] `Accept-Language` presente su login, me, account, change password, forgot (Charles/Proxyman o log) +- [ ] Errori password in it/en/fr/de/es coerenti col backend +- [ ] Hint campo nuova password parla di “min. 8 / 3 tipi” +- [ ] Versione bumpata se lo store release è in scope +- [ ] Nessuna regressione login / lista partite / broadcast + +Quando completo, aggiorna questo file con data + versione iOS rilasciata. diff --git a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt index e1450b1..f15b262 100644 --- a/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt +++ b/native/android/app/src/main/kotlin/com/matchlivetv/match_live_tv/data/AppContainer.kt @@ -2,6 +2,7 @@ package com.matchlivetv.match_live_tv.data import android.content.Context 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.MatchLiveApi import com.matchlivetv.match_live_tv.data.cable.SessionCableService @@ -37,10 +38,20 @@ class AppContainer(context: Context) { accessToken?.let { header("Authorization", "Bearer $it") } header("Accept", "application/json") header("Content-Type", "application/json") + header("Accept-Language", apiLanguageTag()) }.build() chain.proceed(request) } + private fun apiLanguageTag(): String { + val tag = AppLocale.currentTag(appContext) + return if (tag.isBlank()) { + java.util.Locale.getDefault().language.ifBlank { "it" } + } else { + tag + } + } + private val okHttp = OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) diff --git a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift index d76b3ba..fa2f3d7 100644 --- a/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift +++ b/native/ios/MatchLiveTv/Data/API/MatchLiveAPI.swift @@ -240,6 +240,7 @@ final class MatchLiveAPI: @unchecked Sendable { request.httpMethod = "PATCH" request.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type") request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language") if let accessToken { request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") } request.httpBody = MultipartBuilder.build(fields: fields, boundary: boundary) return try await execute(request) @@ -253,6 +254,7 @@ final class MatchLiveAPI: @unchecked Sendable { var request = URLRequest(url: baseURL.appendingPathComponent(path)) request.httpMethod = method request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(AppLanguage.resolvedCode, forHTTPHeaderField: "Accept-Language") if body != nil { request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try ApiInstant.encoder.encode(body)