diff --git a/public/assets/images/esports-arena.jpg b/public/assets/images/esports-arena.jpg new file mode 100644 index 0000000000..366d38bec9 Binary files /dev/null and b/public/assets/images/esports-arena.jpg differ diff --git a/src/components/App/App.tsx b/src/components/App/App.tsx index f0d126ea46..9d1f2a3e9e 100644 --- a/src/components/App/App.tsx +++ b/src/components/App/App.tsx @@ -26,6 +26,7 @@ import Players from "../Players/Players"; import Heroes from "../Heroes/Heroes"; import useStrings from "../../hooks/useStrings.hook"; +const Esports = React.lazy(() => import("../Esports/Esports")); const Status = React.lazy(() => import("../Status/Status")); const Explorer = React.lazy(() => import("../Explorer/Explorer")); @@ -208,6 +209,8 @@ const App = (props: AppProps) => { + + { + const rank = tierOrder.indexOf(tier); + return rank === -1 ? tierOrder.length : rank; +}; + +type League = { leagueid: number; name: string; tier: string | null; banner?: string | null; ticket?: string | null }; +type Team = { team_id: number; name: string; logo_url: string; rating: number; last_match_time: number }; +type Player = { account_id: number; name: string; personaname: string; team_name: string; avatar: string }; +type Hero = { id: number; localized_name: string; pro_pick: number; pro_ban: number; pro_win: number }; +type LeagueMatch = ProfessionalMatch & { radiant_team_name: string | null; dire_team_name: string | null }; +type Resource = { loading: boolean; error: boolean; retry: () => void }; + +/** + * Loading and empty-state inputs for an independent API-backed section. + * @example + * const props: ResourceStatusProps = { + * resource: { loading: false, error: false, retry: () => {} }, empty: true, + * }; + */ +type ResourceStatusProps = { + /** Request state and action for retrying a failed request. */ + resource: Resource; + /** Whether the section contains no visible records. */ + empty: boolean; +}; + +/** + * Displays loading, retry, or empty-state feedback for an independent data resource. + * @param props Resource state and empty-result indicator. + * @param props.resource Loading/error flags and the retry callback. + * @param props.empty Whether the resource has no entries to show. + * @returns Status feedback, or null when data is ready and nonempty. + * @example + * + * // Displays the localized empty-results message. + */ +const ResourceStatus = ({ resource, empty }: ResourceStatusProps) => { + const strings = useStrings(); + if (resource.loading) return

{strings.esports_loading}

; + if (resource.error) return ( +
+

{strings.esports_error}

+ +
+ ); + return empty ?

{strings.esports_empty}

: null; +}; + +/** + * Route parameters supported by the Esports page. + * @example + * const params: EsportsRouteParams = { leagueId: "19719" }; + */ +export type EsportsRouteParams = { + /** Optional directory view, such as tournaments or players. */ + view?: string; + /** League identifier when displaying tournament details. */ + leagueId?: string; +}; + +/** + * Router-provided props for the Esports page, including match, location, and history. + * @example + * // React Router supplies EsportsProps when rendering the route. + * + */ +export type EsportsProps = RouteComponentProps; + +/** + * Renders the Esports route selected by the router parameters. + * @param props React Router route props. + * @param props.match Route match containing an optional directory view or league ID. + * @returns The Esports overview, directory, or tournament detail page. + * @example + * + * // /esports/leagues/19719 renders the league detail view. + */ +export default function Esports({ match }: EsportsProps) { + const strings = useStrings(); + const [search, setSearch] = useState(""); + const [tier, setTier] = useState("all"); + const { leagueId, view } = match.params; + const tournamentsView = view === "tournaments"; + const playersView = view === "players"; + const leagues = useEsportsData("leagues"); + const teams = useEsportsData("teams"); + const recent = useEsportsData(leagueId ? null : "proMatches"); + const leaguesWithProMatches = useMemo(() => { + const matchesByLeague = new Map(); + recent.data.forEach((match) => { + const matches = matchesByLeague.get(match.leagueid) || []; + matches.push(match); + matchesByLeague.set(match.leagueid, matches); + }); + return leagues.data.map((league) => ({ + ...league, + tier: league.tier || "unknown", + matches: (matchesByLeague.get(league.leagueid) || []).map((match) => ({ + ...match, + league_name: league.name, + })), + })); + }, [leagues.data, recent.data]); + const proMatchesWithLeagues = useMemo( + () => leaguesWithProMatches.flatMap((league) => league.matches), + [leaguesWithProMatches], + ); + + const players = useEsportsData(playersView ? "proPlayers" : null); + const heroes = useEsportsData(leagueId || tournamentsView || playersView ? null : "heroStats"); + const leagueGames = useEsportsData(leagueId ? `leagues/${leagueId}/matches` : null); + const league = leagues.data.find((item) => String(item.leagueid) === leagueId); + const normalizedSearch = search.trim().toLowerCase(); + const availableTiers = useMemo( + () => [...new Set(leaguesWithProMatches.map((league) => league.tier))].sort((a, b) => tierRank(a) - tierRank(b) || a.localeCompare(b)), + [leaguesWithProMatches], + ); + const activity = useMemo(() => { + const result = new Map(); + proMatchesWithLeagues.forEach((game) => { + const previous = result.get(game.leagueid); + result.set(game.leagueid, { count: (previous?.count || 0) + 1, latest: Math.max(previous?.latest || 0, game.start_time) }); + }); + return result; + }, [proMatchesWithLeagues]); + const tournamentRows = useMemo(() => leaguesWithProMatches + .map((item) => ({ ...item, latest: activity.get(item.leagueid)?.latest || 0 })) + .filter((item) => (tier === "all" || item.tier === tier) && item.name.toLowerCase().includes(normalizedSearch)) + .sort((a, b) => b.latest - a.latest || tierRank(a.tier) - tierRank(b.tier) || b.leagueid - a.leagueid), + [leaguesWithProMatches, activity, normalizedSearch, tier]); + const recentLeagues = tournamentRows.filter((item) => activity.has(item.leagueid)).slice(0, 6); + const normalizedLeagueGames = useMemo(() => leagueGames.data.map((game) => ({ + ...game, + league_name: league?.name || "", + radiant_name: game.radiant_team_name || game.radiant_name || teams.data.find((team) => team.team_id === game.radiant_team_id)?.name || null, + dire_name: game.dire_team_name || game.dire_name || teams.data.find((team) => team.team_id === game.dire_team_id)?.name || null, + })), [leagueGames.data, league?.name, teams.data]); + const series = groupMatchesBySeries(leagueId ? normalizedLeagueGames : proMatchesWithLeagues); + const filteredSeries = series.filter((item) => [item.league_name, item.team1, item.team2].some((name) => name?.toLowerCase().includes(normalizedSearch))); + const topTeams = [...teams.data].filter((team) => team.last_match_time > Date.now() / 1000 - 180 * 86400) + .sort((a, b) => b.rating - a.rating).slice(0, 10); + const topHeroes = [...heroes.data].sort((a, b) => (b.pro_pick + b.pro_ban) - (a.pro_pick + a.pro_ban)).slice(0, 10); + const filteredPlayers = players.data.filter((player) => [player.name, player.personaname, player.team_name].some((name) => name?.toLowerCase().includes(normalizedSearch))); + const sectionIcons: Record = { + [strings.esports_tournaments]: , + [strings.esports_recent_tournaments]: , + [strings.esports_tournament_details]: , + [strings.esports_players]: , + [strings.esports_top_teams]: , + [strings.esports_contested_heroes]: , + }; + /** + * Returns a localized tier name, retaining readable labels for unfamiliar tiers. + * @param value API tier identifier, or null for an unknown tier. + * @returns A localized display label. + * @example + * tierLabel("premium"); // "Premium" in the English locale + * tierLabel(null); // "Unknown" in the English locale + */ + const tierLabel = (value: string | null) => { + switch (value) { + case "premium": return strings.tier_premium; + case "professional": return strings.tier_professional; + case "amateur": return strings.esports_amateur; + case "excluded": return strings.esports_excluded; + case "unknown": + case null: return strings.general_unknown; + default: return value.charAt(0).toUpperCase() + value.slice(1); + } + }; + /** + * Chooses the theme accent used for a tournament tier. + * @param value API tier identifier, or null when unavailable. + * @returns A theme color token. + * @example + * tierColor("professional"); // constants.colorBlue + */ + const tierColor = (value: string | null) => { + switch (value) { + case "premium": return constants.colorGolden; + case "professional": return constants.colorBlue; + case "amateur": return constants.colorGreen; + default: return constants.textColorSecondary; + } + }; + /** + * Renders a tournament tier as a compact colored badge. + * @param value Tier identifier to label and color. + * @returns A tier badge with a decorative trophy icon. + * @example + * tierBadge("premium"); // Trophy badge with the localized Premium label + */ + const tierBadge = (value: string | null) => ( + + + ); + /** + * Renders a section heading and an optional navigation link. + * @param title Localized heading text used to select a section icon. + * @param to Optional destination for the view-all link. + * @returns An icon heading with an optional view-all link. + * @example + * panelHeader(strings.esports_tournaments, "/esports/tournaments"); + */ + const panelHeader = (title: string, to?: string) => ( +

{sectionIcons[title] || }{title}

{to && {strings.esports_view_all} →}
+ ); + const participatingTeams = new Set(normalizedLeagueGames.flatMap((game) => + [game.radiant_team_id, game.dire_team_id].filter((id) => !!id), + )).size; + const tournamentColumns = [ + { + field: "name", + displayName: strings.esports_tournaments, + displayFn: (row: League) => ( + + + ), + }, + { field: "tier", displayName: strings.esports_tier, displayFn: (row: League) => tierBadge(row.tier) }, + { field: "leagueid", displayName: strings.th_league, sortFn: true }, + { field: "latest", displayName: strings.esports_latest_match, sortFn: true, displayFn: (row: { latest: number }) => row.latest ? : "—" }, + ]; + /** + * Resolves artwork for the league associated with a series. + * @param row Series containing the league ID and display name. + * @returns A league image with initials as its fallback. + * @example + * leagueIcon(seriesRow); // Artwork for seriesRow.leagueid, or league initials + */ + const leagueIcon = (row: MatchSeries) => { + const league = leagues.data.find((item) => item.leagueid === row.leagueid); + return ; + }; + const playerColumns = [ + { field: "name", displayName: strings.esports_players, displayFn: (row: Player) => {row.name || row.personaname || row.account_id} }, + { field: "team_name", displayName: strings.th_team_name, sortFn: true }, + ]; + + const pageHeading = ( + } + className="top-heading" + /> + ); + + return ( + + + + {pageHeading} + + + + {strings.esports_overview} + {strings.esports_tournaments} + + + + {strings.esports_title} + {leagueId && {strings.esports_tournaments}} + + {leagueId + ? league?.name.trim() || leagueId + : tournamentsView ? strings.esports_tournaments + : playersView ? strings.esports_players : strings.esports_overview} + + + + {(tournamentsView || playersView || !leagueId) && ( + + setSearch(event.target.value)} size="small" /> + + )} + + {leagueId ? ( + <> + + {panelHeader(strings.esports_tournament_details, "/esports/tournaments")} + + {league && } +

{strings.esports_league_coverage}

+ {!leagueGames.loading && !leagueGames.error && ( + +
{series.length}{strings.matches_series}
+
{normalizedLeagueGames.length}{strings.th_matches}
+
{participatingTeams}{strings.header_teams}
+
+ )} +
+ + {panelHeader(strings.matches_series)} + +

{strings.esports_schedule_hint}

+ {!leagueGames.loading && } +
+ + {panelHeader(strings.th_matches)} + + `/esports/leagues/${row.leagueid}`, leagueIcon)} + loading={leagueGames.loading} + paginated + keyFn={(row: { key: string }) => row.key} + /> + + + ) : tournamentsView ? ( + + {panelHeader(strings.esports_tournaments)} +

{strings.esports_directory_description}

+ setTier(event.target.value)} size="small" style={{ marginBottom: 20 }}> + + {availableTiers.map((value) => )} + + +
row.leagueid} /> + + ) : playersView ? ( + + {panelHeader(strings.esports_players)} + +
row.account_id} /> + + ) : ( + <> + + {panelHeader(strings.esports_recent_tournaments, "/esports/tournaments")} +

{strings.esports_recent_coverage}

+ + {recentLeagues.map((item) => ( + + + ))} +
+ + {panelHeader(strings.esports_recent_series, "/matches/series")} + +
`/esports/leagues/${row.leagueid}`, leagueIcon)} loading={recent.loading} keyFn={(row: { key: string }) => row.key} /> + + + + {panelHeader(strings.esports_top_teams, "/teams")} +

{strings.esports_team_coverage}

+ +
{row.name} }, + { field: "rating", displayName: strings.th_rating, displayFn: (row: Team) => {Math.round(row.rating)} }, + ]} /> + + + {panelHeader(strings.esports_contested_heroes, "/heroes")} +

{strings.esports_meta_coverage}

+ +
{row.localized_name} }, + { field: "contested", displayName: strings.esports_picks_bans, displayFn: (row: Hero) => row.pro_pick + row.pro_ban }, + { field: "winrate", displayName: strings.th_winrate, displayFn: (row: Hero) => 0 && row.pro_win / row.pro_pick >= 0.5}>{row.pro_pick ? `${Math.round(row.pro_win / row.pro_pick * 100)}%` : "—"} }, + ]} /> + + + + )} + + ); +} diff --git a/src/components/Esports/LeagueIcon.tsx b/src/components/Esports/LeagueIcon.tsx new file mode 100644 index 0000000000..626ec2349a --- /dev/null +++ b/src/components/Esports/LeagueIcon.tsx @@ -0,0 +1,72 @@ +/** + * @file LeagueIcon.tsx + * @description Displays available league artwork with Unicode-aware initials when artwork is missing or fails to load. + */ + +import React, { useState } from "react"; +import styled from "styled-components"; +import constants from "../constants"; + +/** + * Frames league artwork or initials in a fixed-size decorative badge. + * @example + * TI2 + */ +const Icon = styled.span` + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 36px; + height: 36px; + overflow: hidden; + border: 1px solid rgba(100, 160, 210, 0.25); + border-radius: 4px; + background: rgba(100, 160, 210, 0.12); + color: ${constants.colorBlue}; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + font-weight: 600; + img { width: 100%; height: 100%; object-fit: contain; } +`; + +/** + * Artwork sources and identity for a league icon. + * @example + * const props: LeagueIconProps = { name: "The International 2026", banner: null }; + */ +export type LeagueIconProps = { + /** League name used for the image title and fallback initials. */ + name: string; + /** Optional public banner URL; null indicates no supplied artwork. */ + banner?: string | null; + /** Optional public image URL used when no banner URL is supplied. */ + ticket?: string | null; +}; + +/** + * Renders league artwork or initials when no usable image is available. + * @param props League identity and optional artwork sources. + * @param props.name League name used to derive fallback initials. + * @param props.banner Optional public HTTP(S) banner URL. + * @param props.ticket Optional public HTTP(S) ticket image URL, used if no banner URL exists. + * @returns A fixed-size decorative league icon. + * @example + * + * // Displays TI2 when no image is available. + */ +export default function LeagueIcon({ name, banner, ticket }: LeagueIconProps) { + const [failedImage, setFailedImage] = useState(""); + const image = [banner, ticket].find((value) => value && /^https?:\/\//.test(value)) || ""; + const words = name.match(/[\p{L}\p{N}]+/gu) || []; + const initials = (words.length > 1 + ? words.slice(0, 3).map((word) => Array.from(word)[0]).join("") + : Array.from(words[0] || "?").slice(0, 3).join("")).toUpperCase(); + return ( + + ); +} diff --git a/src/components/Esports/SeriesSchedule.tsx b/src/components/Esports/SeriesSchedule.tsx new file mode 100644 index 0000000000..6334fcc756 --- /dev/null +++ b/src/components/Esports/SeriesSchedule.tsx @@ -0,0 +1,196 @@ +/** + * @file SeriesSchedule.tsx + * @description Displays series in collapsible daily groups with team logos, scores, formats, and numbered game links. + * @remarks Dates use the viewer’s local timezone. The latest series is expanded by default. + */ + +import React from "react"; +import { Link } from "react-router-dom"; +import styled from "styled-components"; +import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import useStrings from "../../hooks/useStrings.hook"; +import { MatchSeries } from "../Matches/series"; +import { SeriesTeam } from "../Matches/Matches"; +import TeamLogo from "../TeamLogo/TeamLogo"; +import TableLink from "../Table/TableLink"; +import constants from "../constants"; +import { groupSeriesByDate } from "./groupSeriesByDate"; + +/** + * Arranges date cards in a responsive schedule grid. + * @example + * {dateCards} + */ +const Schedule = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); + align-items: start; + gap: 16px; +`; + +/** + * Provides a collapsible date group with an accessible summary control. + * @example + * October 21, 2026{seriesRows} + */ +const Day = styled.details` + border: 1px solid ${constants.dividerColor}; + border-radius: 3px; + overflow: hidden; + > summary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 12px; + background: ${constants.tableHeaderSurfaceColor}; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeMedium}; + cursor: pointer; + list-style: none; + svg:first-child { color: ${constants.colorBlue}; width: 18px; height: 18px; } + svg:last-child { margin-left: auto; width: 18px; height: 18px; } + &::-webkit-details-marker { display: none; } + } + &[open] > summary > svg:last-child { transform: rotate(180deg); } + summary:focus-visible { outline: 2px solid ${constants.primaryLinkColor}; outline-offset: -2px; } +`; + +/** + * Provides a collapsible matchup row with opposing teams and game details. + * @example + * Alpha 2 – 1 Beta{gameLinks} + */ +const Series = styled.details` + border-top: 1px solid ${constants.dividerColor}; + > summary { + display: grid; + grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr); + align-items: stretch; + cursor: pointer; + list-style: none; + &::-webkit-details-marker { display: none; } + &:hover { background: ${constants.secondarySurfaceColor}; } + > a, > span.team { + display: flex; + align-items: center; + justify-content: space-between; + gap: 6px; + padding: 10px 8px; + color: ${constants.textColorPrimary}; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + overflow-wrap: anywhere; + } + > :last-child { flex-direction: row-reverse; text-align: right; } + .winner { background: rgba(90, 180, 110, .14); color: ${constants.colorGreen}; } + img, .team-logo { width: 28px; height: 28px; flex-shrink: 0; object-fit: contain; } + } +`; + +/** + * Centers the recorded series score and format between the teams. + * @example + * 2 – 1BO3 + */ +const Score = styled.span` + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 3px; + border-left: 1px solid ${constants.dividerColor}; + border-right: 1px solid ${constants.dividerColor}; + font-variant-numeric: tabular-nums; + strong { font-size: ${constants.fontSizeMedium}; } + small { color: ${constants.textColorSecondary}; font-size: ${constants.fontSizeTiny}; } +`; + +/** + * Styles expanded series metadata and vertically ordered game links. + * @example + * {numberedGameLinks} + */ +const Games = styled.div` + padding: 12px; + background: ${constants.secondarySurfaceColor}; + .series-meta { display: block; color: ${constants.textColorSecondary}; margin-bottom: 8px; } + > a { display: block; margin-top: 6px; } +`; + +/** + * Series and team metadata rendered by the daily schedule. + * @example + * const props: SeriesScheduleProps = { series: [], teams: [] }; + */ +export type SeriesScheduleProps = { + /** Grouped series with scores and chronological game records. */ + series: MatchSeries[]; + /** Team records used to resolve logo URLs. */ + teams: SeriesTeam[]; +}; + +/** + * Renders series by local start date, expanding the latest series initially. + * @param props Series schedule data. + * @param props.series Grouped series, including team scores and chronological game lists. + * @param props.teams Team records used to resolve logo URLs. + * @returns Collapsible date cards containing team matchups and game links. + * @example + * const series = groupMatchesBySeries(leagueMatches); + * + * // Groups matches by date and opens the latest series. + */ +export default function SeriesSchedule({ series, teams }: SeriesScheduleProps) { + const strings = useStrings(); + const latestSeries = series.reduce((latest, row) => + !latest || row.first_match_time > latest.first_match_time ? row : latest, + undefined); + /** + * Renders one team’s name and logo with an optional winner highlight. + * @param id Team ID used for the profile link and logo lookup. + * @param name Display name, or null to use the localized unknown label. + * @param winner Whether the recorded score reaches the wins required by the series format. + * @returns A team link, or plain team identity when no ID exists. + * @example + * team(8965331, "Chandogs", true); + * // Team profile link and logo with winner highlighting. + */ + const team = (id: number | null | undefined, name: string | null, winner: boolean) => { + const label = <>{name || strings.general_unknown} item.team_id === id)?.logo_url} style={{ width: 28, height: 28, flexShrink: 0, objectFit: "contain" }} />; + return id ? {label} + : {label}; + }; + return ( + + {groupSeriesByDate(series).map(({ date, series: rows }) => ( + + + {rows.map((row) => { + const type = row.series_type; + const knownFormat = typeof type === "number" && Number.isInteger(type) && type >= 0 && type <= 2; + const format = knownFormat ? `BO${type * 2 + 1}` : strings.general_unknown; + const winsNeeded = knownFormat ? type + 1 : Infinity; + return ( + + + {team(row.team1Id, row.team1, row.team1Wins >= winsNeeded)} + + {row.team1Wins} – {row.team2Wins} + {format} + + {team(row.team2Id, row.team2, row.team2Wins >= winsNeeded)} + + + {row.league_name} · {row.series_id ? `#${row.series_id}` : strings.esports_standalone_match} + {strings.esports_format}: {format} + {row.games.map((game, index) => {strings.matches_game} {index + 1} · {game.match_id})} + + + ); + })} + + ))} + + ); +} diff --git a/src/components/Esports/Styled.ts b/src/components/Esports/Styled.ts new file mode 100644 index 0000000000..4daf7b72f9 --- /dev/null +++ b/src/components/Esports/Styled.ts @@ -0,0 +1,289 @@ +/** + * @file Styled.ts + * @description Defines responsive Esports layouts, tournament cards, badges, statistics, and the shared photo banner. + */ + +import styled from "styled-components"; +import { Link } from "react-router-dom"; +import constants from "../constants"; + +/** + * Provides shared Esports typography and content spacing. + * @example + * {pageContent} + */ +export const Hub = styled.div` + padding-bottom: 24px; + font-family: ${constants.fontFamily}; + font-size: ${constants.fontSizeSmall}; + color: ${constants.textColorPrimary}; + + h2 { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeCommon}; + font-weight: ${constants.fontWeightNormal}; + line-height: 1.6; + svg { width: 20px; height: 20px; flex-shrink: 0; } + } + + p { + color: ${constants.textColorSecondary}; + font-size: ${constants.fontSizeSmall}; + line-height: 1.6; + margin: 0 0 16px; + } + + .MuiInputBase-root, .MuiInputLabel-root, .MuiButton-root { + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + } +`; + +/** + * Separates the section navigation from page content. + * @example + * {tabLinks} + */ +export const Intro = styled.div` + margin-bottom: 16px; + border-bottom: 1px solid ${constants.dividerColor}; +`; + +/** + * Displays responsive Esports navigation with active-route indicators. + * @example + * Overview + */ +export const Navigation = styled.nav` + display: flex; + flex-wrap: wrap; + gap: 4px 16px; + padding-top: 8px; + + a { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 0; + border-bottom: 2px solid transparent; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + color: ${constants.textColorSecondary}; + svg { width: 16px; height: 16px; } + } + + a:hover, a[aria-current="page"] { + color: ${constants.primaryLinkColor}; + border-bottom-color: ${constants.primaryLinkColor}; + text-decoration: none; + } + + a:focus-visible { outline: 2px solid ${constants.primaryLinkColor}; outline-offset: 3px; } + a[href="/esports/tournaments"] svg { color: ${constants.colorGolden}; } + a[href="/teams"] svg { color: ${constants.colorGreen}; } + a[href="/esports/players"] svg { color: ${constants.colorBlue}; } +`; + +/** + * Arranges overview panels in two columns, stacking them on smaller screens. + * @example + * {teams}{heroes} + */ +export const Grid = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + gap: 16px; + align-items: start; + @media (max-width: ${constants.wrapTablet}) { + grid-template-columns: minmax(0, 1fr); + gap: 0; + } +`; + +/** + * Groups an Esports section in a themed, responsive surface. + * @example + * {seriesTable} + */ +export const Panel = styled.section` + min-width: 0; + padding: 16px; + margin-bottom: 16px; + border: 1px solid ${constants.dividerColor}; + border-radius: 3px; + background: ${constants.primarySurfaceColor}; + @media (max-width: ${constants.wrapMobile}) { padding: 12px; } +`; + +/** + * Aligns a section heading with its optional navigation action. + * @example + *

Tournaments

View all
+ */ +export const PanelHeader = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 12px; + a { + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + color: ${constants.primaryLinkColor}; + } +`; + +/** + * Lays out tournament cards in a responsive grid. + * @example + * {tournamentCards} + */ +export const Cards = styled.div` + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); + gap: 12px; +`; + +/** + * Links to tournament details with a highlighted edge and compact identity. + * @example + * The International 2026 + */ +export const TournamentCard = styled(Link)` + display: block; + padding: 14px; + border: 1px solid ${constants.dividerColor}; + border-left: 3px solid ${constants.colorGolden}; + border-radius: 3px; + background: rgba(100, 160, 210, 0.08); + color: ${constants.textColorPrimary}; + transition: border-color 150ms ease; + + &:hover { + border-color: ${constants.colorGolden}; + color: ${constants.textColorPrimary}; + text-decoration: none; + } + &:focus-visible { outline: 2px solid ${constants.primaryLinkColor}; outline-offset: 3px; } + > svg { width: 20px; height: 20px; color: ${constants.colorGolden}; } + strong { + display: block; + margin: 8px 0; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeMedium}; + font-weight: ${constants.fontWeightNormal}; + line-height: 1.5; + overflow-wrap: anywhere; + } + small { + display: block; + font-size: ${constants.fontSizeSmall}; + color: ${constants.textColorSecondary}; + margin-top: 10px; + } +`; + +/** + * Displays a compact tier badge. + * @param props.$premium Whether the default accent should indicate a premium tier. + * @example + * Premium + */ +export const TierBadge = styled.span<{ $premium: boolean }>` + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 6px; + border: 1px solid ${constants.dividerColor}; + border-radius: 3px; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + color: ${({ $premium }) => $premium ? constants.colorGolden : constants.colorBlue}; + svg { width: 12px; height: 12px; } +`; + +/** + * Highlights a numeric statistic with a semantic accent. + * @param props.$positive Whether to use the positive-value accent. + * @example + * 65% + */ +export const Metric = styled.span<{ $positive?: boolean }>` + color: ${({ $positive }) => $positive ? constants.colorGreen : constants.colorBlue}; + font-variant-numeric: tabular-nums; +`; + +/** + * Aligns a team, hero, or player image with its display name. + * @example + * Chandogs + */ +export const Identity = styled.div` + display: flex; + align-items: center; + gap: 8px; + img { width: 28px; height: 28px; object-fit: contain; flex-shrink: 0; } + > svg { width: 24px; height: 24px; color: ${constants.colorGreen}; flex-shrink: 0; } +`; + +/** + * Displays compact counts for tournament series, matches, and teams. + * @example + *
12Series
+ */ +export const Summary = styled.div` + display: flex; + flex-wrap: wrap; + gap: 12px; + > div { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + border: 1px solid ${constants.dividerColor}; + border-radius: 3px; + } + svg { width: 18px; height: 18px; color: ${constants.colorBlue}; } + strong { color: ${constants.colorGolden}; font-size: ${constants.fontSizeCommon}; } + span { color: ${constants.textColorSecondary}; } +`; + +/** + * Places the page heading over a responsive arena photograph and dark overlay. + * @example + * + */ +export const PhotoBanner = styled.div` + position: relative; + margin-top: 16px; + overflow: hidden; + border: 1px solid ${constants.dividerColor}; + border-radius: 4px; + background-color: ${constants.almostBlack}; + background-image: linear-gradient(90deg, rgba(5, 10, 20, .9), rgba(5, 10, 20, .25)), url("/assets/images/esports-arena.jpg"); + background-size: cover; + background-position: center 46%; + + .top-heading { + position: relative; + width: auto; + min-height: 240px; + margin: 0; + left: auto; + right: auto; + padding: 32px 28px; + justify-content: center; + background: transparent; + box-sizing: border-box; + } + .title { color: #fff; } + .subtitle { max-width: 560px; margin: 10px 0 0; color: #e2e6ed; line-height: 1.7; } + @media (max-width: ${constants.wrapMobile}) { + .top-heading { min-height: 200px; padding: 24px 16px 40px; } + } +`; diff --git a/src/components/Esports/TournamentDetails.tsx b/src/components/Esports/TournamentDetails.tsx new file mode 100644 index 0000000000..728eec1b18 --- /dev/null +++ b/src/components/Esports/TournamentDetails.tsx @@ -0,0 +1,154 @@ +/** + * @file TournamentDetails.tsx + * @description Displays tournament identity, available artwork, tier, recorded match dates, and participating teams. + * @remarks Recorded match dates describe available history rather than the official tournament schedule. + */ + +import React, { useState } from "react"; +import styled from "styled-components"; +import EmojiEventsIcon from "@mui/icons-material/EmojiEvents"; +import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; +import useStrings from "../../hooks/useStrings.hook"; +import constants from "../constants"; +import TeamLogo from "../TeamLogo/TeamLogo"; +import { Link } from "react-router-dom"; +import { ProfessionalMatch } from "../Matches/series"; + +/** + * Arranges tournament artwork and metadata in a responsive identity panel. + * @example + *
{artwork}
{metadata}
+ */ +const Details = styled.div` + display: grid; + grid-template-columns: 180px minmax(0, 1fr); + gap: 20px; + margin-bottom: 20px; + .tournament-art { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + gap: 12px; + min-height: 140px; + border: 1px solid rgba(100, 160, 210, 0.25); + border-radius: 4px; + background: rgba(100, 160, 210, 0.1); + color: ${constants.textColorSecondary}; + text-align: center; + img { width: 100%; max-height: 180px; object-fit: contain; } + svg { width: 48px; height: 48px; color: ${constants.colorGolden}; } + } + h2 { margin-bottom: 12px; } + dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 16px; margin: 12px 0; } + dt { color: ${constants.textColorSecondary}; } + dd { margin: 0; overflow-wrap: anywhere; } + dd svg { width: 14px; height: 14px; vertical-align: middle; margin-right: 6px; color: ${constants.colorBlue}; } + @media (max-width: ${constants.wrapMobile}) { grid-template-columns: minmax(0, 1fr); } +`; + +/** + * Displays wrapping team profile links with logos. + * @example + * {teamProfileLinks} + */ +const Participants = styled.div` + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 20px; + a { display: inline-flex; align-items: center; gap: 8px; padding: 6px 10px; border: 1px solid ${constants.dividerColor}; border-radius: 3px; } + img, svg { width: 24px; height: 24px; object-fit: contain; } +`; + +type Tournament = { leagueid: number; name: string; banner?: string | null; ticket?: string | null }; +type Team = { team_id: number; name: string; logo_url: string }; + +/** + * Tournament identity and match-history coverage shown in the details panel. + * @example + * const props: TournamentDetailsProps = { + * league: { leagueid: 19719, name: "The International 2026" }, + * tier: "Professional", games: [], teams: [], historyReady: false, + * }; + */ +export type TournamentDetailsProps = { + /** Tournament identity and optional artwork sources. */ + league: Tournament; + /** Localized text or badge representing the tournament tier. */ + tier: React.ReactNode; + /** Recorded matches used to derive dates and participants. */ + games: ProfessionalMatch[]; + /** Team metadata used to resolve participant names and logos. */ + teams: Team[]; + /** True after match history loads successfully. */ + historyReady: boolean; +}; + +/** + * Displays tournament identity and the coverage available in its recorded history. + * @param props Tournament metadata and match history. + * @param props.league Tournament name, ID, and optional artwork sources. + * @param props.tier Localized tier badge to display. + * @param props.games Match history used to derive dates and participating teams. + * @param props.teams Team metadata used to resolve names and logos. + * @param props.historyReady Whether match history has loaded successfully. + * @returns Tournament artwork, metadata, and available participant links. + * @example + * Professional} + * games={leagueMatches} + * teams={teams} + * historyReady={true} + * /> + */ +export default function TournamentDetails({ league, tier, games, teams, historyReady }: TournamentDetailsProps) { + const strings = useStrings(); + const [failedImage, setFailedImage] = useState(""); + // Historical banner values can be asset identifiers rather than public URLs. + const image = [league.banner, league.ticket].find((value) => value && /^https?:\/\//.test(value)) || ""; + const dates = games.map((game) => game.start_time); + /** + * Formats a recorded match timestamp in the viewer’s locale. + * @param value Unix timestamp in seconds. + * @returns A localized calendar date including the year. + * @example + * date(new Date(2026, 9, 21, 12).getTime() / 1000); + * // "Oct 21, 2026" in an en-US locale. + */ + const date = (value: number) => new Date(value * 1000).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + const participants = new Map(); + games.forEach((game) => { + for (const [id, name] of [[game.radiant_team_id, game.radiant_name], [game.dire_team_id, game.dire_name]] as const) { + if (!id || participants.has(id)) continue; + const team = teams.find((item) => item.team_id === id); + participants.set(id, { name: name || team?.name || strings.general_unknown, logo_url: team?.logo_url || "" }); + } + }); + + return ( + <> +
+
+ {image && failedImage !== image ? {league.name} setFailedImage(image)} /> : <>
+
+

{league.name}

+ {tier} +
+
{strings.th_league}
{league.leagueid}
+ {historyReady && dates.length > 0 && <> +
{strings.esports_first_recorded_match}
+
{strings.esports_last_recorded_match}
+ } +
+
+
+ {historyReady && participants.size > 0 && <> +

{strings.esports_participating_teams}

+ {Array.from(participants, ([id, team]) => {team.name})} + } + + ); +} diff --git a/src/components/Esports/groupSeriesByDate.ts b/src/components/Esports/groupSeriesByDate.ts new file mode 100644 index 0000000000..69ee7b14fa --- /dev/null +++ b/src/components/Esports/groupSeriesByDate.ts @@ -0,0 +1,42 @@ +/** + * @file groupSeriesByDate.ts + * @description Groups series chronologically by their first match’s local calendar date without mutating the input. + */ + +import { MatchSeries } from "../Matches/series"; + +/** + * Groups series by the local date of their earliest recorded game. + * @param series Series to group; input order and contents are preserved. + * @returns Date groups ordered oldest first, with series ordered by start time within each day. + * @example + * // Other MatchSeries fields are omitted here for readability. + * // Date constructors use local time, matching the grouping behavior. + * const input = [ + * { key: "19719:3", first_match_time: new Date(2026, 9, 21, 10).getTime() / 1000 }, + * { key: "19719:1", first_match_time: new Date(2026, 9, 20, 9).getTime() / 1000 }, + * { key: "19719:2", first_match_time: new Date(2026, 9, 20, 15).getTime() / 1000 }, + * ]; + * groupSeriesByDate(input); + * // Returns: + * // [ + * // { date: "2026-10-20", series: [input[1], input[2]] }, + * // { date: "2026-10-21", series: [input[0]] }, + * // ] + * // Each series retains all its original fields; input is not reordered. + * + * @example + * groupSeriesByDate([]); + * // Returns: [] + */ +export const groupSeriesByDate = (series: MatchSeries[]) => { + const days = new Map(); + [...series].sort((a, b) => a.first_match_time - b.first_match_time).forEach((row) => { + const start = new Date(row.first_match_time * 1000); + const date = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`; + const rows = days.get(date) || []; + rows.push(row); + days.set(date, rows); + }); + return Array.from(days, ([date, rows]) => ({ date, series: rows })); +}; diff --git a/src/components/Esports/useEsportsData.ts b/src/components/Esports/useEsportsData.ts new file mode 100644 index 0000000000..693460840e --- /dev/null +++ b/src/components/Esports/useEsportsData.ts @@ -0,0 +1,60 @@ +/** + * @file useEsportsData.ts + * @description Loads list responses from OpenDota with independent loading, error, cancellation, and retry state. + * @remarks A null path disables fetching. Obsolete responses are discarded when the path changes or the component unmounts. + */ + +import { useEffect, useState } from "react"; +import config from "../../config"; + +/** + * Fetches an OpenDota list and manages request cancellation and manual retries. + * @template T The element type expected in the API response. + * @param path API-relative path, such as leagues; null disables the request. + * @returns The current data, loading/error flags, and a retry callback. + * @example + * const { data, loading, error, retry } = useEsportsData("leagues"); + * // Inside a component: data becomes the league array after a successful request. + * const history = useEsportsData(leagueId ? `leagues/${leagueId}/matches` : null); + * // No history request is made when leagueId is absent. + */ +export default function useEsportsData(path: string | null) { + const [state, setState] = useState<{ + path: string | null; + data: T[]; + loading: boolean; + error: boolean; + }>({ path, data: [], loading: !!path, error: false }); + const [attempt, setAttempt] = useState(0); + + useEffect(() => { + if (!path) return; + const controller = new AbortController(); + setState({ path, data: [], loading: true, error: false }); + fetch(`${config.VITE_API_HOST}/api/${path}`, { + credentials: "include", + signal: controller.signal, + }) + .then((response) => { + if (!response.ok) throw new Error(String(response.status)); + return response.json(); + }) + .then((data) => { + if (!Array.isArray(data)) throw new Error("Expected a list"); + if (!controller.signal.aborted) { + setState({ path, data, loading: false, error: false }); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setState({ path, data: [], loading: false, error: true }); + } + }); + return () => controller.abort(); + }, [path, attempt]); + + return { + ...(state.path === path ? state : { data: [], loading: !!path, error: false }), + retry: () => setAttempt((value) => value + 1), + }; +} diff --git a/src/components/Header/Header.tsx b/src/components/Header/Header.tsx index 12967f7949..7fa453f5c4 100644 --- a/src/components/Header/Header.tsx +++ b/src/components/Header/Header.tsx @@ -285,6 +285,7 @@ const Header = ({ to: "/request", label: strings.header_request, }, + { key: "esports", to: "/esports", label: strings.esports_title }, { key: "header_matches", to: "/matches", diff --git a/src/components/Matches/Matches.tsx b/src/components/Matches/Matches.tsx index 107cb554ae..fd879b16c5 100644 --- a/src/components/Matches/Matches.tsx +++ b/src/components/Matches/Matches.tsx @@ -4,10 +4,9 @@ import Helmet from "react-helmet"; import { heroes } from "dotaconstants"; import styled from "styled-components"; import { transformations, subTextStyle, rankTierToString } from "../../utility"; -import { getProMatches, getPublicMatches } from "../../actions"; +import { getProMatches, getPublicMatches, getTeams } from "../../actions"; import Table from "../Table/Table"; import TableLink from "../Table/TableLink"; -// import Heading from '../Heading'; import { IconTrophy } from "../Icons"; import Match from "../Match/Match"; import TabBar from "../TabBar/TabBar"; @@ -15,7 +14,9 @@ import { StyledTeamIconContainer } from "../Match/StyledMatch"; import constants from "../constants"; import FromNowTooltip from "../Visualizations/FromNowTooltip"; import HeroImage from "../Visualizations/HeroImage"; +import TeamLogo from "../TeamLogo/TeamLogo"; import Heading from "../Heading/Heading"; +import { groupMatchesBySeries, MatchSeries } from "./series"; export const WinnerSpan = styled.span` display: inline-block; @@ -28,7 +29,29 @@ export const WinnerSpan = styled.span` } `; -const matchesColumns = (strings: Strings) => [ +/** + * Displays a muted best-of format label. + * @param props.$type Series type code: 0 for BO1, 1 for BO3, or 2 for BO5. + * @example + * BO3 + */ +const SeriesFormatBadge = styled.span<{ $type: number }>` + display: inline-flex; + align-items: center; + padding: 2px 6px; + border: 1px solid ${constants.dividerColor}; + border-radius: 4px; + background: transparent; + color: ${constants.textColorSecondary}; + font-family: ${constants.fontFamilyFuturistic}; + font-size: ${constants.fontSizeSmall}; + font-weight: 400; + letter-spacing: 0.01em; + line-height: 1.4; + white-space: nowrap; +`; + +export const matchesColumns = (strings: Strings) => [ { field: "version", displayFn: (row: any, col: any, field: any) => ( @@ -97,6 +120,107 @@ const matchesColumns = (strings: Strings) => [ }, ]; +export type SeriesTeam = { team_id: number; logo_url: string }; + +/** + * Renders a series participant with its logo and optional team profile link. + * @param id Team identifier when available. + * @param name Team name, or null for the unknown label. + * @param teams Team metadata used for logo lookup. + * @param strings Localized interface strings. + * @returns The participant identity and logo. + * @example + * seriesTeam(8965331, "Chandogs", teams, strings); + */ +const seriesTeam = (id: number | null | undefined, name: string | null, teams: SeriesTeam[], strings: Strings) => { + const src = teams.find((team) => team.team_id === id)?.logo_url || ""; + const label = name || strings.general_unknown; + return ( +
+ + {id ? {label} : label} +
+ ); +}; + +/** + * Builds the shared columns for grouped professional series tables. + * @param strings Localized column labels and fallback messages. + * @param teams Team records used to resolve logos. + * @param seriesLink Optional function returning each series or league detail URL. + * @param leagueIcon Optional renderer for artwork beside the league name. + * @returns Table column definitions for league, format, teams, score, and game links. + * @example + * const columns = seriesColumns(strings, teams, (row) => `/esports/leagues/${row.leagueid}`); + *
+ */ +export const seriesColumns = ( + strings: Strings, + teams: SeriesTeam[], + seriesLink = (row: MatchSeries) => `/matches/series/${row.key}`, + leagueIcon?: (row: MatchSeries) => React.ReactNode, +) => [ + { + displayName: strings.th_league, + field: "league_name", + displayFn: (row: MatchSeries) => ( +
+ {leagueIcon?.(row)} +
+ + {row.league_name} + +
+ +
+
+
+ ), + }, + { + displayName: strings.esports_format, + field: "series_type", + displayFn: (row: MatchSeries) => { + const type = row.series_type; + return typeof type === "number" && Number.isInteger(type) && type >= 0 && type <= 2 + ? BO{type * 2 + 1} + : strings.general_unknown; + }, + }, + { + displayName: strings.th_team_name, + field: "team1", + displayFn: (row: MatchSeries) => seriesTeam(row.team1Id, row.team1, teams, strings), + }, + { + displayName: strings.th_score, + field: "score", + displayFn: (row: MatchSeries) => ( + + {row.team1Wins} – {row.team2Wins} + + ), + }, + { + displayName: strings.th_team_name, + field: "team2", + displayFn: (row: MatchSeries) => seriesTeam(row.team2Id, row.team2, teams, strings), + }, + { + displayName: strings.th_matches, + field: "games", + displayFn: (row: MatchSeries) => ( +
+ {row.games.map((game, index) => ( + + {strings.matches_game} {index + 1} · {game.match_id} + + ))} +
+ ), + }, +]; + const publicMatchesColumns = (strings: Strings) => [ { displayName: strings.th_match_id, @@ -176,6 +300,20 @@ const matchTabs = (strings: Strings) => [ ), route: "/matches/pro", }, + { + name: strings.matches_series, + key: "series", + content: (propsPar: MatchesProps) => ( +
row.key} + loading={propsPar.loading} + loadingText="Loading professional series..." + /> + ), + route: "/matches/series", + }, { name: strings.matches_highest_mmr, key: "highMmr", @@ -196,6 +334,7 @@ const matchTabs = (strings: Strings) => [ const getData = (props: MatchesProps) => { const route = props.match.params.matchId || "pro"; if (!Number.isInteger(Number(route))) { + if (route === "series") props.dispatchTeams(); props.dispatchProMatches(); props.dispatchPublicMatches({ min_rank: 75 }); } @@ -205,6 +344,8 @@ type MatchesProps = { match: { params: { matchId: string; info?: string } }; strings: Strings; dispatchProMatches: Function; + dispatchTeams: Function; + teams: SeriesTeam[]; dispatchPublicMatches: Function; proData: any[]; publicData: any[]; @@ -229,12 +370,52 @@ class Matches extends React.Component { return ; } + if (route === "series" && this.props.match.params.info) { + const series = groupMatchesBySeries(this.props.proData).find( + (row) => row.key === this.props.match.params.info, + ); + return ( +
+ + {strings.matches_series} + + {strings.th_league}: {series.leagueid} + {!!series.series_id && ` / ${strings.matches_series}: ${series.series_id}`} + + )} + className="top-heading" + /> +
+ {series && ( + <> + +

{strings.matches_series_available_games}

+
game.match_id} + /> + + )} + + ); + } + const tab = matchTabs(strings).find((_tab) => _tab.key === route); return (
- + item.key !== "series")} /> {tab && tab.content(this.props)}
); @@ -243,12 +424,14 @@ class Matches extends React.Component { const mapStateToProps = (state: any) => ({ proData: state.app.proMatches.data, + teams: state.app.teams.data, publicData: state.app.publicMatches.data, loading: state.app.proMatches.loading, strings: state.app.strings, }); const mapDispatchToProps = (dispatch: any) => ({ + dispatchTeams: () => dispatch(getTeams()), dispatchProMatches: () => dispatch(getProMatches()), dispatchPublicMatches: (options: any) => dispatch(getPublicMatches(options)), }); diff --git a/src/components/Matches/series.ts b/src/components/Matches/series.ts new file mode 100644 index 0000000000..f7178c5250 --- /dev/null +++ b/src/components/Matches/series.ts @@ -0,0 +1,87 @@ +/** + * @file series.ts + * @description Defines professional match types and groups league matches into chronologically ordered series. + * @remarks Scores follow team identity across Radiant/Dire swaps. Matches without a series ID remain separate. + */ + +export type ProfessionalMatch = { + match_id: number; + series_id?: number | null; + series_type?: number | null; + leagueid: number; + league_name: string; + start_time: number; + duration: number; + radiant_team_id?: number | null; + dire_team_id?: number | null; + radiant_name: string | null; + dire_name: string | null; + radiant_win: boolean | null; +}; + +/** + * Groups matches by league and series ID and calculates scores across side swaps. + * @param matches Available professional match records; matches lacking a valid series ID remain separate. + * @returns Series ordered by latest game end time, with games ordered chronologically. + * @example + * const series = groupMatchesBySeries([game1, game2]); + * // If both games share leagueid and series_id, returns one series with + * // games: [game1, game2] ordered by start time, and wins credited by team ID. + * groupMatchesBySeries([]); // [] + */ +export const groupMatchesBySeries = (matches: ProfessionalMatch[]) => { + const groups = new Map(); + matches.forEach((match) => { + const key = match.series_id && match.series_id > 0 + ? `${match.leagueid}:${match.series_id}` + : `match:${match.match_id}`; + const group = groups.get(key) || []; + group.push(match); + groups.set(key, group); + }); + + return Array.from(groups, ([key, games]) => { + const orderedGames = [...games].sort((a, b) => a.start_time - b.start_time || a.match_id - b.match_id); + const first = orderedGames[0]; + /** + * Builds a side-independent team identifier for score attribution. + * @param id Team ID when available. + * @param name Fallback team name when no ID is provided. + * @returns A team identifier prefixed by its source. + * @example + * teamKey(8965331, "Chandogs"); // "id:8965331" + * teamKey(null, "Chandogs"); // "name:Chandogs" + */ + const teamKey = (id: number | null | undefined, name: string | null) => + id ? `id:${id}` : `name:${name}`; + const team1Key = teamKey(first.radiant_team_id, first.radiant_name); + let team1Wins = 0; + let team2Wins = 0; + orderedGames.forEach((game) => { + if (typeof game.radiant_win !== "boolean") return; + const winner = game.radiant_win + ? teamKey(game.radiant_team_id, game.radiant_name) + : teamKey(game.dire_team_id, game.dire_name); + if (winner === team1Key) team1Wins += 1; + else team2Wins += 1; + }); + return { + key, + leagueid: first.leagueid, + series_id: first.series_id, + series_type: first.series_type, + first_match_time: first.start_time, + league_name: first.league_name, + start_time: Math.max(...games.map((game) => game.start_time + game.duration)), + team1Id: first.radiant_team_id, + team2Id: first.dire_team_id, + team1: first.radiant_name, + team2: first.dire_name, + team1Wins, + team2Wins, + games: orderedGames, + }; + }).sort((a, b) => b.start_time - a.start_time); +}; + +export type MatchSeries = ReturnType[number]; diff --git a/src/components/TeamLogo/TeamLogo.tsx b/src/components/TeamLogo/TeamLogo.tsx new file mode 100644 index 0000000000..45168c1514 --- /dev/null +++ b/src/components/TeamLogo/TeamLogo.tsx @@ -0,0 +1,99 @@ +/** + * @file TeamLogo.tsx + * @description Displays team logos with cached team-detail lookups and a generic icon fallback. + * @remarks Team details are requested only when the supplied logo is missing or fails to load. + */ + +import React, { useEffect, useState } from "react"; +import GroupsIcon from "@mui/icons-material/Groups"; +import config from "../../config"; +import { getTeamLogoUrl } from "../../utility"; + +// Reuse the detail lookup when a team appears in several series on the page. +const logoRequests = new Map>(); + +/** + * Looks up a team-detail logo, sharing cached requests for the same team. + * @param teamId OpenDota team ID. + * @returns A promise resolving to a logo URL, or an empty string when unavailable. + * @example + * const url = await getLogo(8965331); + * // Resolves to the team detail logo URL, or "" if unavailable. + */ +const getLogo = (teamId: number) => { + let request = logoRequests.get(teamId); + if (!request) { + request = fetch(`${config.VITE_API_HOST}/api/teams/${teamId}`, { + credentials: "include", + }) + .then((response) => { + if (!response.ok) throw new Error(String(response.status)); + return response.json(); + }) + .then((team) => getTeamLogoUrl(team.logo_url || "")) + .catch(() => { + logoRequests.delete(teamId); + return ""; + }); + logoRequests.set(teamId, request); + } + return request; +}; + +/** + * Team logo sources and presentation options. + * @example + * const props: TeamLogoProps = { teamId: 8965331, style: { width: 32, height: 32 } }; + */ +export type TeamLogoProps = { + /** Optional team ID used for fallback artwork lookup. */ + teamId?: number | null; + /** Optional logo URL from the team list; defaults to an empty string. */ + src?: string; + /** Inline image and fallback-icon layout styles. */ + style?: React.CSSProperties; +}; + +/** + * Displays a supplied logo, falls back to team details, then to a generic team icon. + * @param props Logo identity and presentation options. + * @param props.teamId Optional team ID for the fallback detail request. + * @param props.src Optional logo URL from the team list. + * @param props.style Inline size and layout styles applied to the image or fallback icon. + * @returns A decorative logo image or team icon. + * @example + * + * // Looks up the logo when src is omitted; shows a generic icon if unavailable. + */ +const TeamLogo = ({ + teamId, + src = "", + style, +}: TeamLogoProps) => { + const [detail, setDetail] = useState({ teamId, src: "" }); + const [failedSrc, setFailedSrc] = useState(""); + const listSrc = getTeamLogoUrl(src); + const needsDetail = !listSrc || failedSrc === listSrc; + const logo = needsDetail ? (detail.teamId === teamId ? detail.src : "") : listSrc; + + useEffect(() => { + if (!teamId || !needsDetail) return; + let active = true; + getLogo(teamId).then((url) => { + if (active) setDetail({ teamId, src: url }); + }); + return () => { active = false; }; + }, [teamId, needsDetail]); + + return logo && logo !== failedSrc ? ( + setFailedSrc(logo)} + style={style} + /> + ) :