/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import React from "react"; import { useDispatch, useSelector } from "react-redux"; import { actionCreators as ac, actionTypes as at } from "common/Actions.mjs"; const PREF_SPORTS_WIDGET_SIZE = "widgets.sportsWidget.size"; const USER_ACTION_TYPES = { OPEN_MATCH_SEARCH: "open_match_search", }; // Visible placeholder shown in place of a team's country code when the // match-up isn't decided yet. const TBD_PLACEHOLDER = "--"; // The /matches API has been observed sending the American spelling // "canceled" alongside the British "cancelled" we localise to. Map both keys // to the same Fluent IDs (see UPCOMING_STATUS_ARIA_L10N_MAP below) so the // badge and aria-label render either way without a data-team-side fix. const UPCOMING_STATUS_L10N_MAP = { delayed: "newtab-sports-widget-delayed", postponed: "newtab-sports-widget-postponed", suspended: "newtab-sports-widget-suspended", cancelled: "newtab-sports-widget-cancelled", canceled: "newtab-sports-widget-cancelled", }; const RESULTS_STATUS_L10N_MAP = { final: "newtab-sports-widget-match-full-time", }; const UPCOMING_STATUS_ARIA_L10N_MAP = { delayed: "newtab-sports-widget-match-aria-label-upcoming-delayed", postponed: "newtab-sports-widget-match-aria-label-upcoming-postponed", suspended: "newtab-sports-widget-match-aria-label-upcoming-suspended", cancelled: "newtab-sports-widget-match-aria-label-upcoming-cancelled", canceled: "newtab-sports-widget-match-aria-label-upcoming-cancelled", }; // status_type is always "live" on the /wcs/live endpoint, so the actual // sub-state has to be read from `period` and `status`. Matching rules mirror // Fenix's MatchesResponseMapper.kt. Returns null when no rule matches. function liveStatusL10nId({ period, status: matchStatus, home_penalty, away_penalty, }) { const normalisedPeriod = (period || "").toLowerCase().replace(/\s+/g, ""); const inShootout = normalisedPeriod === "pen" || normalisedPeriod === "penaltyshootout" || (home_penalty !== null && home_penalty !== undefined) || (away_penalty !== null && away_penalty !== undefined); const isExtraTime = /et/i.test(period || "") || /extra/i.test(period || ""); const isHalftime = matchStatus?.toLowerCase() === "break"; if (inShootout) { return "newtab-sports-widget-match-penalties"; } if (isExtraTime) { return "newtab-sports-widget-match-extra-time"; } if (isHalftime) { return "newtab-sports-widget-match-halftime"; } return null; } function ScorePill({ homeScore, awayScore, homePenalty, awayPenalty, variant, }) { return (
)} > ); } function SportsMatchRow({ match, variant, size = "large", handleInteraction, followedTeams, tbdTeamName = "", localizedNames, }) { const dispatch = useDispatch(); // Read the widget size pref (not `size`, which can be "list" when the // user expanded the view) so the telemetry event below reports the user's // actual chosen size. const widgetSize = useSelector( state => state.Prefs.values[PREF_SPORTS_WIDGET_SIZE] || "medium" ); const { home_team, away_team, date, status_type, period, status: matchStatus, home_score, away_score, home_penalty, away_penalty, query, } = match; const isHomeFollowed = !!(home_team && followedTeams?.has(home_team.key)); const isAwayFollowed = !!(away_team && followedTeams?.has(away_team.key)); const homeTeamName = home_team ? (localizedNames?.[home_team.key] ?? home_team.name) : tbdTeamName; const awayTeamName = away_team ? (localizedNames?.[away_team.key] ?? away_team.name) : tbdTeamName; const dateTimestamp = new Date(date).getTime(); const displayHomeScore = home_score || 0; const displayAwayScore = away_score || 0; // A match went to a shootout only when both penalty scores are present. // Checking both guards against asymmetric/corrupt data where one side is // null — which would otherwise pass a `null` into the aria-label args. const hasPenalties = home_penalty !== null && home_penalty !== undefined && away_penalty !== null && away_penalty !== undefined; // Picks the Fluent message + args used to translate the row's aria-label. // We pick a separate Fluent ID per sub-case (penalty shootout for results, // non-scheduled status for upcoming) instead of using Fluent selectors, so // translators see complete sentences and the strings are independently // translatable. function getAriaLabelL10n() { const teams = { homeTeam: homeTeamName, awayTeam: awayTeamName }; if (variant === "results") { if (hasPenalties) { return { id: "newtab-sports-widget-match-aria-label-results-penalties", args: { ...teams, homeScore: displayHomeScore, awayScore: displayAwayScore, homePenalty: home_penalty, awayPenalty: away_penalty, }, }; } return { id: "newtab-sports-widget-match-aria-label-results", args: { ...teams, homeScore: displayHomeScore, awayScore: displayAwayScore, }, }; } if (variant === "now") { return { id: "newtab-sports-widget-match-aria-label-now", args: { ...teams, homeScore: displayHomeScore, awayScore: displayAwayScore, }, }; } // Upcoming. Non-scheduled statuses use a per-status Fluent ID; the // default ("scheduled") announces kickoff time/date. const upcomingId = UPCOMING_STATUS_ARIA_L10N_MAP[status_type?.toLowerCase()] || "newtab-sports-widget-match-aria-label-upcoming"; return { id: upcomingId, args: { ...teams, date: dateTimestamp }, }; } const ariaLabelL10n = getAriaLabelL10n(); function renderMiddle() { switch (variant) { case "now": { const liveL10nId = liveStatusL10nId({ period, status: matchStatus, home_penalty, away_penalty, }); // The Now tab's live status footer is only shown in the large widget. if (!liveL10nId || size !== "large") { return (