/* 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 (
{homePenalty !== null && homePenalty !== undefined && ( ({homePenalty}) )} {homeScore} {awayScore} {awayPenalty !== null && awayPenalty !== undefined && ( ({awayPenalty}) )}
); } // Renders one side of a match row: the team flag and code, or a placeholder // when the team is not yet decided. function MatchTeam({ team, isFollowed, localizedName }) { if (!team) { return (
{TBD_PLACEHOLDER}
); } const displayName = localizedName ?? team.name; // Display the ISO region to match other platforms; team.key stays the // internal identity key. Fall back to key when region is absent. const displayCode = team.region ?? team.key; return (
{displayName} {isFollowed && ( {isFollowed ? {displayCode} : displayCode}
); } // Fallback shown in the Upcoming tab if the backend returns no matches. function UpcomingMatchPlaceholder({ size = "large" }) { return ( <> {size === "large" && (

)} ); } 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 ( ); } return (
); } case "results": { // Per Figma the Results footer is always "Full time" (optionally // "• Penalties"); default to it for unmapped status_types so any // stale live-state value that leaks into this bucket doesn't render // raw API text in the UI. const resultsStatusL10nId = RESULTS_STATUS_L10N_MAP[status_type?.toLowerCase()] || "newtab-sports-widget-match-full-time"; // The medium widget lacks room for "Full time • Penalties", so for a // penalty shootout it shows "Penalties" alone instead. Large keeps // both; list shows only the status (as in the "view all" view). const mediumPenaltiesOnly = hasPenalties && size === "medium"; return (
{hasPenalties && size === "large" && ( <> )}
); } // Default is the upcoming variant default: { const statusL10nId = UPCOMING_STATUS_L10N_MAP[status_type?.toLowerCase()]; const dateArgs = JSON.stringify({ date: dateTimestamp }); return (
{statusL10nId ? ( ) : ( )}
); } } } // Hand the click off to the main process, which calls // SearchUIUtils.loadSearch to resolve the user's default engine, navigate // (handling POST + private windows), and record SAP telemetry. We also // dispatch a WIDGETS_USER_EVENT so newtab-side telemetry can attribute // the click to the right tab variant + widget size. function openMatchSearch(event) { if (!query) { return; } event.preventDefault(); dispatch( ac.OnlyToMain({ type: at.WIDGETS_USER_EVENT, data: { widget_name: "sports", widget_source: "widget", user_action: USER_ACTION_TYPES.OPEN_MATCH_SEARCH, action_value: variant, widget_size: widgetSize, }, }) ); dispatch( ac.OnlyToMain({ type: at.WIDGETS_SPORTS_OPEN_MATCH_SEARCH, data: { query, eventInfo: { button: event.button, shiftKey: event.shiftKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey, altKey: event.altKey, }, }, }) ); handleInteraction?.(); } function onKeyDown(event) { // Anchor without an href doesn't fire click on Enter/Space, so wire it // up manually to keep keyboard activation working. if (event.key === "Enter" || event.key === " ") { openMatchSearch(event); } } const clickable = !!query; return ( {/* (developer note): Replace href with SERP link. */} {renderMiddle()} ); } export { SportsMatchRow, UpcomingMatchPlaceholder };