Aggiunge annunci in-app/sito, contatti e migliora UI copertina sul portale.

Include admin/API/banner nativi, form contatti e reset copertina standard con layout edit più ampio.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-20 15:30:49 +02:00
co-authored by Cursor
parent e502844b88
commit 5b723bf844
81 changed files with 2177 additions and 38 deletions
@@ -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