From 459c39a0eadd9fd203c96abe008c027bf3c56c41 Mon Sep 17 00:00:00 2001 From: Ruslan Khayrullin Date: Mon, 9 Jan 2023 12:48:51 +0500 Subject: [PATCH] feat(in-142): match stats tab --- public/images/matchTabs/bets.svg | 3 + public/images/matchTabs/chat.svg | 3 + public/images/matchTabs/players.svg | 6 + public/images/matchTabs/plays.svg | 4 + public/images/matchTabs/stats.svg | 12 + public/images/matchTabs/watch.svg | 3 + public/images/sortUp.svg | 3 + src/config/index.tsx | 1 + src/config/keyboardKeys.tsx | 3 + src/config/lexics/indexLexics.tsx | 5 + src/config/routes.tsx | 7 + src/features/CircleAnimationBar/index.tsx | 43 ++- src/features/CircleAnimationBar/styled.tsx | 21 +- src/features/LexicsStore/helpers/index.tsx | 4 + src/features/LexicsStore/hooks/index.tsx | 2 + .../MatchCard/CardFrontside/index.tsx | 22 ++ src/features/MatchCard/styled.tsx | 26 +- .../components/FinishedMatch/helpers.tsx | 1 + .../components/FinishedMatch/hooks/index.tsx | 14 +- .../components/FinishedMatch/index.tsx | 12 +- .../components/LiveMatch/helpers.tsx | 65 ++-- .../components/LiveMatch/hooks/index.tsx | 6 - .../LiveMatch/hooks/useChapters.tsx | 11 +- .../MatchPage/components/LiveMatch/index.tsx | 10 +- .../components/MatchDescription/index.tsx | 2 +- .../MatchPage/helpers/fullMatchDuration.tsx | 11 - .../MatchPage/helpers/getHalfTime.tsx | 53 ++++ src/features/MatchPage/store/hooks/index.tsx | 148 +++++++-- .../MatchPage/store/hooks/useMatchData.tsx | 16 +- .../store/hooks/useMatchPlaylists.tsx | 4 +- .../MatchPage/store/hooks/usePlayersStats.tsx | 201 +++++++++++++ .../store/hooks/useSelectedPlaylist.tsx | 13 +- .../MatchPage/store/hooks/useStatsTab.tsx | 144 +++++++++ .../MatchPage/store/hooks/useTeamsStats.tsx | 113 +++++++ .../store/hooks/useTournamentData.tsx | 14 +- .../components/CircleAnimationBar/index.tsx | 18 ++ .../components/EventButton/index.tsx | 41 ++- .../components/MatchPlaylists/index.tsx | 46 +-- .../{TabVideo => Matches}/index.tsx | 51 ++-- .../components/Matches/styled.tsx | 40 +++ .../components/PlayButton/index.tsx | 5 +- .../components/PlayersPlaylists/index.tsx | 9 +- .../components/PlayersPlaylists/styled.tsx | 33 +- .../components/PlayersTable/Cell.tsx | 87 ++++++ .../components/PlayersTable/config.tsx | 7 + .../components/PlayersTable/hooks/index.tsx | 73 +++++ .../PlayersTable/hooks/usePlayers.tsx | 79 +++++ .../PlayersTable/hooks/useTable.tsx | 262 ++++++++++++++++ .../components/PlayersTable/index.tsx | 201 +++++++++++++ .../components/PlayersTable/styled.tsx | 283 ++++++++++++++++++ .../components/PlayersTable/types.tsx | 9 + .../components/TabEvents/index.tsx | 25 +- .../components/TabEvents/styled.tsx | 10 +- .../components/TabPlayers/index.tsx | 25 ++ .../components/TabStats/config.tsx | 10 + .../components/TabStats/hooks.tsx | 77 +++++ .../components/TabStats/index.tsx | 165 ++++++++++ .../components/TabStats/styled.tsx | 134 +++++++++ .../TabVideo/components/VideoDate/index.tsx | 92 ------ .../TabVideo/components/VideoDate/styled.tsx | 52 ---- .../components/TabVideo/styled.tsx | 22 -- .../components/TabWatch/index.tsx | 78 +++-- .../components/TeamsStatsTable/Cell.tsx | 176 +++++++++++ .../components/TeamsStatsTable/hooks.tsx | 31 ++ .../components/TeamsStatsTable/index.tsx | 90 ++++++ .../components/TeamsStatsTable/styled.tsx | 126 ++++++++ src/features/MatchSidePlaylists/config.tsx | 3 +- src/features/MatchSidePlaylists/hooks.tsx | 73 +++-- src/features/MatchSidePlaylists/index.tsx | 57 ++-- src/features/MatchSidePlaylists/styled.tsx | 100 ++++++- .../MultiSourcePlayer/hooks/index.tsx | 56 +++- src/features/MultiSourcePlayer/types.tsx | 1 + src/features/Name/index.tsx | 11 +- src/features/StreamPlayer/hooks/index.tsx | 35 +-- src/features/T9n/index.tsx | 3 + src/features/Tooltip/index.tsx | 7 +- src/helpers/getTeamAbbr/index.tsx | 25 ++ src/helpers/index.tsx | 1 + src/hooks/index.tsx | 2 + src/hooks/useModalRoot.tsx | 5 + src/hooks/usePageParams.tsx | 1 + src/hooks/useTooltip.tsx | 89 ++++++ src/requests/getMatchEvents.tsx | 1 + src/requests/getMatchInfo.tsx | 12 +- src/requests/getMatchParticipants.tsx | 69 +++++ src/requests/getMatchScore.tsx | 11 +- src/requests/getPlayersStats.tsx | 58 ++++ src/requests/getStatsEvents.tsx | 57 ++++ src/requests/getTeamsStats.tsx | 60 ++++ src/requests/index.tsx | 4 + 90 files changed, 3604 insertions(+), 500 deletions(-) create mode 100644 public/images/matchTabs/bets.svg create mode 100644 public/images/matchTabs/chat.svg create mode 100644 public/images/matchTabs/players.svg create mode 100644 public/images/matchTabs/plays.svg create mode 100644 public/images/matchTabs/stats.svg create mode 100644 public/images/matchTabs/watch.svg create mode 100644 public/images/sortUp.svg create mode 100644 src/config/keyboardKeys.tsx delete mode 100644 src/features/MatchPage/helpers/fullMatchDuration.tsx create mode 100644 src/features/MatchPage/helpers/getHalfTime.tsx create mode 100644 src/features/MatchPage/store/hooks/usePlayersStats.tsx create mode 100644 src/features/MatchPage/store/hooks/useStatsTab.tsx create mode 100644 src/features/MatchPage/store/hooks/useTeamsStats.tsx create mode 100644 src/features/MatchSidePlaylists/components/CircleAnimationBar/index.tsx rename src/features/MatchSidePlaylists/components/{TabVideo => Matches}/index.tsx (53%) create mode 100644 src/features/MatchSidePlaylists/components/Matches/styled.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/Cell.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/config.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/hooks/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/types.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabPlayers/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabStats/config.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabStats/hooks.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabStats/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/TabStats/styled.tsx delete mode 100644 src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx delete mode 100644 src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx delete mode 100644 src/features/MatchSidePlaylists/components/TabVideo/styled.tsx create mode 100644 src/features/MatchSidePlaylists/components/TeamsStatsTable/Cell.tsx create mode 100644 src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx create mode 100644 src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx create mode 100644 src/helpers/getTeamAbbr/index.tsx create mode 100644 src/hooks/useModalRoot.tsx create mode 100644 src/hooks/useTooltip.tsx create mode 100644 src/requests/getMatchParticipants.tsx create mode 100644 src/requests/getPlayersStats.tsx create mode 100644 src/requests/getStatsEvents.tsx create mode 100644 src/requests/getTeamsStats.tsx diff --git a/public/images/matchTabs/bets.svg b/public/images/matchTabs/bets.svg new file mode 100644 index 00000000..d112cddb --- /dev/null +++ b/public/images/matchTabs/bets.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/matchTabs/chat.svg b/public/images/matchTabs/chat.svg new file mode 100644 index 00000000..02d75c4b --- /dev/null +++ b/public/images/matchTabs/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/matchTabs/players.svg b/public/images/matchTabs/players.svg new file mode 100644 index 00000000..ffa6cb02 --- /dev/null +++ b/public/images/matchTabs/players.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/matchTabs/plays.svg b/public/images/matchTabs/plays.svg new file mode 100644 index 00000000..0850a8e7 --- /dev/null +++ b/public/images/matchTabs/plays.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/matchTabs/stats.svg b/public/images/matchTabs/stats.svg new file mode 100644 index 00000000..12afb401 --- /dev/null +++ b/public/images/matchTabs/stats.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/matchTabs/watch.svg b/public/images/matchTabs/watch.svg new file mode 100644 index 00000000..fdb9114f --- /dev/null +++ b/public/images/matchTabs/watch.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/sortUp.svg b/public/images/sortUp.svg new file mode 100644 index 00000000..0f51e40c --- /dev/null +++ b/public/images/sortUp.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/config/index.tsx b/src/config/index.tsx index 357bcd8f..165cff95 100644 --- a/src/config/index.tsx +++ b/src/config/index.tsx @@ -11,3 +11,4 @@ export * from './dashes' export * from './env' export * from './userAgent' export * from './queries' +export * from './keyboardKeys' diff --git a/src/config/keyboardKeys.tsx b/src/config/keyboardKeys.tsx new file mode 100644 index 00000000..97d3b125 --- /dev/null +++ b/src/config/keyboardKeys.tsx @@ -0,0 +1,3 @@ +export enum KEYBOARD_KEYS { + Enter = 'Enter', +} diff --git a/src/config/lexics/indexLexics.tsx b/src/config/lexics/indexLexics.tsx index 9e2f6955..4ea9695b 100644 --- a/src/config/lexics/indexLexics.tsx +++ b/src/config/lexics/indexLexics.tsx @@ -15,6 +15,7 @@ const matchPopupLexics = { display_stats_according_to_video: 19931, episode_duration: 13410, events: 1020, + final_stats: 19591, from_end_match: 15396, from_price: 3992, from_start_match: 15395, @@ -27,6 +28,8 @@ const matchPopupLexics = { match_interviews: 13031, match_settings: 13490, no_data: 15397, + other_games: 19997, + others: 19902, players_episodes: 13398, playlist_format: 13406, playlist_format_all_actions: 13408, @@ -39,6 +42,7 @@ const matchPopupLexics = { sign_in: 20003, sign_in_full_game: 20004, started_streaming_at: 16042, + stats: 18179, streamed_live_on: 16043, video: 1017, views: 13440, @@ -166,6 +170,7 @@ export const indexLexics = { no_match_access_body: 13419, no_match_access_title: 13418, player: 14975, + players: 164, players_video: 13032, privacy_policy_and_statement: 15404, round_highilights: 13050, diff --git a/src/config/routes.tsx b/src/config/routes.tsx index dd866e19..220b0cd5 100644 --- a/src/config/routes.tsx +++ b/src/config/routes.tsx @@ -23,6 +23,12 @@ const VIEWS_APIS = { staging: 'https://views.test.insports.tv', } +const STATS_APIS = { + preproduction: 'https://statistic.insports.tv', + production: 'https://statistic.insports.tv', + staging: 'https://statistic-stage.insports.tv', +} + const env = isProduction ? ENV : readSelectedApi() ?? ENV export const VIEWS_API = VIEWS_APIS[env] @@ -30,3 +36,4 @@ export const AUTH_SERVICE = APIS[env].auth export const API_ROOT = APIS[env].api export const DATA_URL = `${API_ROOT}/data` export const URL_AWS = 'https://cf-aws.insports.tv' +export const STATS_API_URL = STATS_APIS[env] diff --git a/src/features/CircleAnimationBar/index.tsx b/src/features/CircleAnimationBar/index.tsx index 7139059d..4228993f 100644 --- a/src/features/CircleAnimationBar/index.tsx +++ b/src/features/CircleAnimationBar/index.tsx @@ -4,7 +4,7 @@ import { useEffect } from 'react' import isEmpty from 'lodash/isEmpty' import size from 'lodash/size' -import type { Events } from 'requests' +import { useMatchPageStore } from 'features/MatchPage/store' import { fullEpisodesDuration } from './helpers' import { Svg, Circle } from './styled' @@ -24,26 +24,33 @@ export const initialCircleAnimation: TCircleAnimation = { } type Props = { - circleAnimation?: TCircleAnimation, - filteredEvents: Events, - setWatchAllEpisodesTimer: (showTimer: boolean) => void, + className?: string, + size?: number, + text?: string, } export type TSetCircleAnimation = Dispatch> export const CircleAnimationBar = ({ - circleAnimation, - filteredEvents, - setWatchAllEpisodesTimer, + className, + size: svgSize = 14, + text, }: Props) => { + const { + circleAnimation, + filteredEvents, + setWatchAllEpisodesTimer, + } = useMatchPageStore() + const { plaingOrder, playedProgress, playing, ready, - } = circleAnimation! + } = circleAnimation + const timeOfAllEpisodes = fullEpisodesDuration(filteredEvents) - const remainingEvents = filteredEvents.slice(plaingOrder - 1) + const remainingEvents = filteredEvents.slice(plaingOrder && plaingOrder - 1) const fullTimeOfRemainingEpisodes = !isEmpty(remainingEvents) ? fullEpisodesDuration(remainingEvents) : 0 @@ -52,6 +59,8 @@ export const CircleAnimationBar = ({ const currentAnimationTime = Math.round(fullTimeOfRemainingEpisodes - (playedProgress / 1000)) const currentEpisodesPercent = 100 - (100 / (timeOfAllEpisodes / currentAnimationTime)) + const strokeDashOffset = svgSize * Math.PI + useEffect(() => { if (currentEpisodesPercent >= 100 && (plaingOrder === size(filteredEvents))) { setWatchAllEpisodesTimer(false) @@ -64,7 +73,10 @@ export const CircleAnimationBar = ({ ]) return ( - + + {text && ( + + {text} + + )} ) } diff --git a/src/features/CircleAnimationBar/styled.tsx b/src/features/CircleAnimationBar/styled.tsx index 230f5734..eb50140f 100644 --- a/src/features/CircleAnimationBar/styled.tsx +++ b/src/features/CircleAnimationBar/styled.tsx @@ -4,9 +4,12 @@ type TCircle = { animationPause?: boolean, currentAnimationTime: number, currentEpisodesPercent: number, + strokeDashOffset: number, } -const strokeDashOffset = 43.5 +type SvgProps = { + size: number, +} const clockAnimation = (currentEpisodesPercent?: number) => keyframes` from { @@ -17,10 +20,12 @@ const clockAnimation = (currentEpisodesPercent?: number) => keyframes` } ` -export const Svg = styled.svg` +export const Svg = styled.svg` + --size: ${({ size }) => `${size}px`}; + background-color: #5EB2FF; - width: 14px; - height: 14px; + width: var(--size); + height: var(--size); position: relative; border-radius: 50%; ` @@ -28,12 +33,12 @@ export const Svg = styled.svg` export const Circle = styled.circle` fill: transparent; stroke: white; - stroke-width: 14px; - stroke-dasharray: ${strokeDashOffset}; - stroke-dashoffset: ${strokeDashOffset}; + stroke-width: var(--size); + stroke-dasharray: ${({ strokeDashOffset }) => strokeDashOffset}; + stroke-dashoffset: ${({ strokeDashOffset }) => strokeDashOffset}; transform: rotate(-90deg); transform-origin: center; - animation-name: ${({ currentEpisodesPercent }) => ( + animation-name: ${({ currentEpisodesPercent, strokeDashOffset }) => ( clockAnimation(strokeDashOffset - (strokeDashOffset * currentEpisodesPercent / 100)) )}; animation-duration: ${({ currentAnimationTime }) => `${currentAnimationTime}s`}; diff --git a/src/features/LexicsStore/helpers/index.tsx b/src/features/LexicsStore/helpers/index.tsx index bad471da..8c77e285 100644 --- a/src/features/LexicsStore/helpers/index.tsx +++ b/src/features/LexicsStore/helpers/index.tsx @@ -12,6 +12,10 @@ export const getSuffix = (lang: string) => ( lang === 'ru' ? 'rus' : 'eng' ) +export const getShortSuffix = (lang: string) => ( + lang === 'ru' ? 'ru' : 'en' +) + export const getLexicIds = (ids: Array | LexicsConfig) => ( uniq(map(ids, (id) => Number(id))) ) diff --git a/src/features/LexicsStore/hooks/index.tsx b/src/features/LexicsStore/hooks/index.tsx index 74fefed4..23db4405 100644 --- a/src/features/LexicsStore/hooks/index.tsx +++ b/src/features/LexicsStore/hooks/index.tsx @@ -8,6 +8,7 @@ import { getLexicIds, mapTranslationsToLocalKeys, getSuffix, + getShortSuffix, } from 'features/LexicsStore/helpers' import { useLang } from './useLang' @@ -57,6 +58,7 @@ export const useLexics = (initialLanguage?: string) => { changeLang, lang, languageList, + shortSuffix: getShortSuffix(lang), suffix: getSuffix(lang), translate, } as const diff --git a/src/features/MatchCard/CardFrontside/index.tsx b/src/features/MatchCard/CardFrontside/index.tsx index 78445d08..f8900bf3 100644 --- a/src/features/MatchCard/CardFrontside/index.tsx +++ b/src/features/MatchCard/CardFrontside/index.tsx @@ -167,6 +167,17 @@ export const CardFrontside = ({ + {isMatchPage && ( + + )} {team1InFavorites && } @@ -174,6 +185,17 @@ export const CardFrontside = ({ + {isMatchPage && ( + + )} {team2InFavorites && } diff --git a/src/features/MatchCard/styled.tsx b/src/features/MatchCard/styled.tsx index 43d84166..4c94755e 100644 --- a/src/features/MatchCard/styled.tsx +++ b/src/features/MatchCard/styled.tsx @@ -89,9 +89,8 @@ export const PreviewWrapper = styled.div` isMatchPage ? css` position: absolute; - bottom: 8px; - height: auto; - width: calc(100% - 1.25rem); + inset: 0; + height: 100%; ` : '' )} @@ -109,8 +108,7 @@ export const Preview = styled.img` width: 100%; height: 100%; object-fit: cover; - opacity: 0.4; - display: ${({ isMatchPage }) => (isMatchPage ? 'none' : 'block')}; + opacity: 0.2; ` export const MatchTimeInfo = styled.div` @@ -124,11 +122,10 @@ export const MatchTimeInfo = styled.div` ${({ isMatchPage }) => ( isMatchPage ? css` - position: initial; - padding: 0; + top: auto; + bottom: 0.519rem; flex-direction: row-reverse; justify-content: flex-end; - align-items: center; ` : '' )} @@ -186,10 +183,12 @@ export const Time = styled.span` ` export const Info = styled.div` + position: relative; display: flex; flex-direction: column; padding: ${({ isMatchPage }) => (isMatchPage ? '0 5px 5px 0' : '0.85rem 0.472rem 0 0.519rem')}; color: #fff; + z-index: 1; ${isMobileDevice ? css` @@ -251,6 +250,7 @@ export const Team = styled.span` ${({ isMatchPage }) => ( isMatchPage ? css` + font-size: 18px; line-height: 28px; ` : '' @@ -284,7 +284,7 @@ export const TeamLogos = styled.div` z-index: 1; ` -export const TeamLogo = styled(ProfileLogo)` +export const TeamLogo = styled(ProfileLogo)` width: 33%; max-height: 100%; @@ -297,6 +297,14 @@ export const TeamLogo = styled(ProfileLogo)` width: 30%; ` : ''}; + + ${({ isMatchPage }) => (isMatchPage + ? css` + width: 18px; + margin-right: 5px; + ` + : '') +} ` export const BuyMatchBadge = styled.span` diff --git a/src/features/MatchPage/components/FinishedMatch/helpers.tsx b/src/features/MatchPage/components/FinishedMatch/helpers.tsx index 9639a342..46c5663d 100644 --- a/src/features/MatchPage/components/FinishedMatch/helpers.tsx +++ b/src/features/MatchPage/components/FinishedMatch/helpers.tsx @@ -69,6 +69,7 @@ const getFullMatchChapters = (videos: Array +
+ + + {showLeftArrow && ( + + + + )} + {showExpandButton && ( + + + + + )} + + {map(params, ({ + id, + lexic, + lexica_short, + }) => ( + + + + ))} + +
+ + + {map(players, (player) => { + const playerName = getPlayerName(player) + const playerNum = player.num ?? player.club_shirt_num + const playerProfileUrl = `/${sportName}/players/${player.id}` + + return ( + + + {playerNum}{' '} + + {playerName} + + + {map(params, (param) => { + const playerParam = getPlayerParams(player.id)[ + param.id + ] as PlayerParam | undefined + const value = playerParam ? getDisplayedValue(playerParam) : '-' + // eslint-disable-next-line max-len + const clickable = Boolean(playerParam?.clickable) && !includes([0, '-'], value) + const sorted = sortCondition.paramId === param.id + const onClick = () => { + clickable && handleParamClick(param.id, player.id) + } + + return ( + + {watchAllEpisodesTimer + && param.id === playingData.player.paramId + && player.id === playingData.player.id + ? ( + + ) + : value} + + ) + })} + + ) + })} + +
+ + )} + + ) +} diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx new file mode 100644 index 00000000..84d1721b --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx @@ -0,0 +1,283 @@ +import { Link } from 'react-router-dom' + +import styled, { css } from 'styled-components/macro' + +import { isIOS, isMobileDevice } from 'config' + +import { customScrollbar } from 'features/Common' +import { + ArrowButton as ArrowButtonBase, + Arrow as ArrowBase, +} from 'features/HeaderFilters/components/DateFilter/styled' +import { T9n } from 'features/T9n' + +type ContainerProps = { + isExpanded?: boolean, +} + +export const Container = styled.div` + --bgColor: #333; + + ${({ isExpanded }) => (isExpanded + ? css` + --bgColor: rgba(51, 51, 51, 0.7); + ` + : css` + position: relative; + `)} +` + +type TableWrapperProps = { + isExpanded?: boolean, +} + +export const TableWrapper = styled.div` + max-width: 100%; + clip-path: inset(0 0 0 0 round 5px); + overflow-x: auto; + scroll-behavior: smooth; + background: + linear-gradient(180deg, #292929 44px, var(--bgColor) 44px), + linear-gradient(-90deg, #333 8px, var(--bgColor) 8px); + z-index: 50; + + ${customScrollbar} + + ::-webkit-scrollbar-thumb:vertical { + background: linear-gradient(180deg, transparent 44px, #3F3F3F 44px); + } + + ${({ isExpanded }) => (isExpanded + ? css` + position: absolute; + right: 14px; + ` + : '')} + + ${isMobileDevice + ? '' + : css` + max-height: calc(100vh - 203px); + `}; + + ${isIOS + ? css` + overscroll-behavior: none; + ` + : ''}; +` + +export const Table = styled.table` + border-radius: 5px; + border-spacing: 0; + border-collapse: collapse; + letter-spacing: -0.078px; + table-layout: fixed; +` + +type ParamShortTitleProps = { + showLeftArrow?: boolean, + sortDirection: 'asc' | 'desc', + sorted?: boolean, +} + +export const ParamShortTitle = styled(T9n)` + position: relative; + text-transform: uppercase; + + ::before { + position: absolute; + content: ''; + top: 50%; + left: -9px; + translate: 0 -50%; + rotate: ${({ sortDirection }) => (sortDirection === 'asc' ? 0 : 180)}deg; + width: 7px; + height: 7px; + background-image: url(/images/sortUp.svg); + background-size: cover; + + ${({ sorted }) => (sorted + ? '' + : css` + display: none; + `)} + + ${({ showLeftArrow }) => (showLeftArrow + ? '' + : css` + z-index: 1; + `)} + } +` + +export const PlayerNum = styled.span` + display: inline-block; + width: 17px; + flex-shrink: 0; + color: rgba(255, 255, 255, 0.5); +` + +export const PlayerName = styled(Link)` + display: inline-block; + vertical-align: middle; + text-overflow: ellipsis; + color: ${({ theme }) => theme.colors.white}; + overflow: hidden; +` + +type CellContainerProps = { + as?: 'td' | 'th', + clickable?: boolean, + columnWidth?: number, + hasValue?: boolean, + sorted?: boolean, +} + +export const CellContainer = styled.td.attrs(({ clickable }: CellContainerProps) => ({ + ...clickable && { tabIndex: 0 }, +}))` + position: relative; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + width: ${({ columnWidth }) => (columnWidth ? `${columnWidth}px` : 'auto')}; + min-width: 30px; + font-size: 11px; + font-weight: ${({ clickable }) => (clickable ? 700 : 400)}; + color: ${({ clickable, theme }) => (clickable ? '#5EB2FF' : theme.colors.white)}; + white-space: nowrap; + background-color: var(--bgColor); + + :first-child { + position: sticky; + left: 0; + justify-content: unset; + padding-left: 10px; + text-align: left; + cursor: unset; + z-index: 1; + } + + ${({ clickable }) => (clickable + ? css` + cursor: pointer; + ` + : '')} + + ${({ as, sorted }) => (as === 'th' + ? css` + font-weight: ${sorted ? '700' : '600'}; + font-size: ${sorted ? 13 : 11}px; + ` + : '')} + + ${({ hasValue }) => (!hasValue + ? css` + color: rgba(255, 255, 255, 0.5); + ` + : '')} +` + +export const Row = styled.tr` + position: relative; + display: flex; + width: 100%; + height: 45px; + border-bottom: 0.5px solid #5C5C5C; + z-index: 1; + + :last-child:not(:first-child) { + border: none; + } + + :hover { + ${CellContainer}:not(th) { + background-color: #484848; + } + + ${PlayerName} { + text-decoration: underline; + font-weight: 600; + } + } +` + +export const Header = styled.thead` + position: sticky; + left: 0; + top: 0; + z-index: 2; + + ${Row} { + border-bottom-color: ${({ theme }) => theme.colors.secondary}; + } + + ${CellContainer} { + background-color: #292929; + color: ${({ theme }) => theme.colors.white}; + cursor: pointer; + } + + ${CellContainer}:first-child { + cursor: unset; + } +` + +export const Arrow = styled(ArrowBase)` + width: 10px; + height: 10px; + + ${isMobileDevice + ? css` + border-color: ${({ theme }) => theme.colors.white}; + ` + : ''}; +` + +const ArrowButton = styled(ArrowButtonBase)` + position: absolute; + width: 20px; + height: 44px; + margin-top: 0; + z-index: 3; + background-color: #292929; + + ${isMobileDevice + ? css` + margin-top: 0; + ` + : ''}; +` + +export const ArrowButtonRight = styled(ArrowButton)` + right: 0; + border-top-right-radius: 5px; + + ${Arrow} { + left: auto; + right: 7px; + } +` + +export const ArrowButtonLeft = styled(ArrowButton)` + right: -5px; +` + +type ExpandButtonProps = { + isExpanded?: boolean, +} + +export const ExpandButton = styled(ArrowButton)` + left: 20px; + top: 0; + + ${Arrow} { + left: ${({ isExpanded }) => (isExpanded ? -6 : -2)}px; + + :last-child { + margin-left: 7px; + } + } +` diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/types.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/types.tsx new file mode 100644 index 00000000..a5717bd4 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/types.tsx @@ -0,0 +1,9 @@ +export type PlayersTableProps = { + teamId: number, +} + +export type SortCondition = { + clicksCount: number, + dir: 'asc' | 'desc', + paramId: number | null, +} diff --git a/src/features/MatchSidePlaylists/components/TabEvents/index.tsx b/src/features/MatchSidePlaylists/components/TabEvents/index.tsx index 205e0ff2..7813e38c 100644 --- a/src/features/MatchSidePlaylists/components/TabEvents/index.tsx +++ b/src/features/MatchSidePlaylists/components/TabEvents/index.tsx @@ -8,7 +8,6 @@ import size from 'lodash/size' import { T9n } from 'features/T9n' import type { PlaylistOption } from 'features/MatchPage/types' import { useMatchPageStore } from 'features/MatchPage/store' -import type { TCircleAnimation, TSetCircleAnimation } from 'features/CircleAnimationBar' import { CircleAnimationBar } from 'features/CircleAnimationBar' import type { MatchInfo } from 'requests' @@ -30,25 +29,20 @@ import { } from './styled' type Props = { - circleAnimation?: TCircleAnimation, onSelect: (option: PlaylistOption) => void, profile: MatchInfo, selectedPlaylist?: PlaylistOption, - setCircleAnimation?: TSetCircleAnimation, } export const TabEvents = ({ - circleAnimation, onSelect, profile, selectedPlaylist, - setCircleAnimation, }: Props) => { const { activeStatus, countOfFilters, disablePlayingEpisodes, - filteredEvents, isEmptyFilters, isLiveMatch, likeImage, @@ -56,6 +50,7 @@ export const TabEvents = ({ plaingOrder, playEpisodes, reversedGroupEvents, + setCircleAnimation, setReversed, setUnreversed, setWatchAllEpisodesTimer, @@ -64,12 +59,10 @@ export const TabEvents = ({ } = useMatchPageStore() useEffect(() => { - if (setCircleAnimation) { - setCircleAnimation((state) => ({ - ...state, - plaingOrder, - })) - } + setCircleAnimation((state) => ({ + ...state, + plaingOrder, + })) }, [setCircleAnimation, plaingOrder]) if (!profile) return null @@ -101,15 +94,11 @@ export const TabEvents = ({ {size(flatten(reversedGroupEvents))} - + playEpisodes()}> {watchAllEpisodesTimer && ( - + )} )} diff --git a/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx b/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx index 69b0f102..f5e082d8 100644 --- a/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx +++ b/src/features/MatchSidePlaylists/components/TabEvents/styled.tsx @@ -3,6 +3,7 @@ import styled, { css } from 'styled-components/macro' import { isMobileDevice } from 'config/userAgent' import { ProfileLogo } from 'features/ProfileLogo' +import { NameStyled } from 'features/Name' import { Tabs as TabsBase } from '../PlayersPlaylists/styled' @@ -118,6 +119,10 @@ export const SubTitle = styled(Title)` font-weight: normal; color: rgba(255, 255, 255, 0.7); margin-top: 2px; + + ${NameStyled} { + font-weight: 700; + } ` export const EventLike = styled.img` @@ -159,6 +164,7 @@ export const Tab = styled.span` cursor: pointer; width: 13px; height: 13px; + display: inline-block; :nth-child(2) { margin-left: 3px; @@ -210,10 +216,6 @@ export const Button = styled(ButtonBase) ` background-size: cover; ` : '' )} - - &:hover ${EventDesc} { - overflow: visible; - } ${({ isHomeTeam }) => ( isHomeTeam diff --git a/src/features/MatchSidePlaylists/components/TabPlayers/index.tsx b/src/features/MatchSidePlaylists/components/TabPlayers/index.tsx new file mode 100644 index 00000000..f65c4b88 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabPlayers/index.tsx @@ -0,0 +1,25 @@ +import type { Playlists, PlaylistOption } from 'features/MatchPage/types' +import type { MatchInfo } from 'requests' + +import { PlayersPlaylists } from '../PlayersPlaylists' + +type Props = { + onSelect: (option: PlaylistOption) => void, + playlists: Playlists, + profile: MatchInfo, + selectedPlaylist?: PlaylistOption, +} + +export const TabPlayers = ({ + onSelect, + playlists, + profile, + selectedPlaylist, +}: Props) => ( + +) diff --git a/src/features/MatchSidePlaylists/components/TabStats/config.tsx b/src/features/MatchSidePlaylists/components/TabStats/config.tsx new file mode 100644 index 00000000..3c14e7ff --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/config.tsx @@ -0,0 +1,10 @@ +export enum Tabs { + TEAMS, + TEAM1, + TEAM2, +} + +export enum StatsType { + FINAL_STATS, + CURRENT_STATS, +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/hooks.tsx b/src/features/MatchSidePlaylists/components/TabStats/hooks.tsx new file mode 100644 index 00000000..ea8df099 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/hooks.tsx @@ -0,0 +1,77 @@ +import { useEffect, useState } from 'react' + +import isEmpty from 'lodash/isEmpty' + +import { useTooltip } from 'hooks' + +import { useMatchPageStore } from 'features/MatchPage/store' + +import { StatsType, Tabs } from './config' + +export const useTabStats = () => { + const [selectedTab, setSelectedTab] = useState(Tabs.TEAMS) + + const { + isEmptyPlayersStats, + profile: matchProfile, + statsType, + teamsStats, + toggleStatsType, + } = useMatchPageStore() + + const { + isTooltipShown, + onMouseLeave, + onMouseOver, + tooltipStyle, + tooltipText, + } = useTooltip() + + const isFinalStatsType = statsType === StatsType.FINAL_STATS + + const switchTitleLexic = isFinalStatsType ? 'final_stats' : 'current_stats' + const switchButtonTooltipLexic = isFinalStatsType ? 'display_all_stats' : 'display_stats_according_to_video' + + const isVisibleTeamsTab = !isEmpty(teamsStats) + const isVisibleTeam1PlayersTab = Boolean( + matchProfile && !isEmptyPlayersStats(matchProfile.team1.id), + ) + const isVisibleTeam2PlayersTab = Boolean( + matchProfile && !isEmptyPlayersStats(matchProfile.team2.id), + ) + + useEffect(() => { + switch (true) { + case isVisibleTeamsTab: + setSelectedTab(Tabs.TEAMS) + break + + case isVisibleTeam1PlayersTab: + setSelectedTab(Tabs.TEAM1) + break + + case isVisibleTeam2PlayersTab: + setSelectedTab(Tabs.TEAM2) + break + + default: + } + }, [isVisibleTeam1PlayersTab, isVisibleTeam2PlayersTab, isVisibleTeamsTab]) + + return { + isFinalStatsType, + isTooltipShown, + isVisibleTeam1PlayersTab, + isVisibleTeam2PlayersTab, + isVisibleTeamsTab, + onMouseLeave, + onMouseOver, + selectedTab, + setSelectedTab, + switchButtonTooltipLexic, + switchTitleLexic, + toggleStatsType, + tooltipStyle, + tooltipText, + } +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/index.tsx b/src/features/MatchSidePlaylists/components/TabStats/index.tsx new file mode 100644 index 00000000..297e4465 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/index.tsx @@ -0,0 +1,165 @@ +import type { ComponentProps } from 'react' +import { createPortal } from 'react-dom' + +import { isMobileDevice } from 'config' + +import { getTeamAbbr } from 'helpers' + +import { useModalRoot } from 'hooks' + +import { T9n } from 'features/T9n' +import { useMatchPageStore } from 'features/MatchPage/store' +import { Name } from 'features/Name' +import { useLexicsStore } from 'features/LexicsStore' + +import { Tabs } from './config' +import { useTabStats } from './hooks' +import { PlayersTable } from '../PlayersTable' +import { TeamsStatsTable } from '../TeamsStatsTable' + +import { + Container, + Header, + TabList, + Tab, + Switch, + SwitchTitle, + SwitchButton, + Tooltip, + TabTitle, +} from './styled' + +const tabPanes = { + [Tabs.TEAMS]: TeamsStatsTable, + // eslint-disable-next-line react/jsx-props-no-spreading + [Tabs.TEAM1]: (props: ComponentProps) => , + // eslint-disable-next-line react/jsx-props-no-spreading + [Tabs.TEAM2]: (props: ComponentProps) => , +} + +export const TabStats = () => { + const { + isFinalStatsType, + isTooltipShown, + isVisibleTeam1PlayersTab, + isVisibleTeam2PlayersTab, + isVisibleTeamsTab, + onMouseLeave, + onMouseOver, + selectedTab, + setSelectedTab, + switchButtonTooltipLexic, + switchTitleLexic, + toggleStatsType, + tooltipStyle, + tooltipText, + } = useTabStats() + const { profile: matchProfile } = useMatchPageStore() + const { suffix, translate } = useLexicsStore() + + const modalRoot = useModalRoot() + + const TabPane = tabPanes[selectedTab] + + if (!matchProfile) return null + + const { team1, team2 } = matchProfile + + return ( + +
+ + {isVisibleTeamsTab && ( + setSelectedTab(Tabs.TEAMS)} + > + + + + + )} + {isVisibleTeam1PlayersTab && ( + setSelectedTab(Tabs.TEAM1)} + > + + + + + )} + {isVisibleTeam2PlayersTab && ( + setSelectedTab(Tabs.TEAM2)} + > + + + + + )} + + + + + +
+ + {isTooltipShown && modalRoot.current && createPortal( + + {tooltipText} + , + modalRoot.current, + )} +
+ ) +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/styled.tsx b/src/features/MatchSidePlaylists/components/TabStats/styled.tsx new file mode 100644 index 00000000..a295bf35 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/styled.tsx @@ -0,0 +1,134 @@ +import styled, { css } from 'styled-components/macro' + +import { TooltipWrapper } from 'features/Tooltip' +import { T9n } from 'features/T9n' + +export const Container = styled.div`` + +export const Header = styled.div` + display: flex; + justify-content: space-between; + margin-bottom: 6px; +` + +export const TabList = styled.div.attrs({ role: 'tablist' })` + display: flex; +` + +export const Tooltip = styled(TooltipWrapper)` + display: flex; + justify-content: center; + align-items: center; + height: 17px; + padding: 0 10px; + border-radius: 6px; + transform: none; + font-size: 11px; + line-height: 1; + color: ${({ theme }) => theme.colors.black}; + + ::before { + display: none; + } +` + +type TabTitleProps = { + teamColor?: string | null, +} + +export const TabTitle = styled.span` + position: relative; + color: rgba(255, 255, 255, 0.5); + + ${({ teamColor, theme }) => (teamColor + ? css` + ::before { + content: ''; + position: absolute; + left: -8px; + top: 50%; + translate: 0 -50%; + width: 5px; + height: 5px; + outline: ${teamColor.toUpperCase() === theme.colors.white ? 'none' : `0.5px solid ${theme.colors.white}`}; + border-radius: 50%; + background-color: ${teamColor}; + } + ` + : '' + )} +` + +export const Tab = styled.button.attrs({ role: 'tab' })` + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px 10px; + font-size: 12px; + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + + &[aria-pressed="true"] { + border-color: ${({ theme }) => theme.colors.white}; + + ${TabTitle} { + color: ${({ theme }) => theme.colors.white}; + } + } +` + +export const Switch = styled.div` + display: flex; +` + +export const SwitchTitle = styled(T9n)` + font-size: 12px; + color: ${({ theme }) => theme.colors.white}; + white-space: nowrap; +` + +type SwitchButtonProps = { + isFinalStatsType: boolean, +} + +export const SwitchButton = styled.button` + width: 20px; + height: 7px; + margin-left: 5px; + margin-top: 5px; + border-radius: 2px; + border: none; + border: 1px solid ${({ theme }) => theme.colors.white}; + cursor: pointer; + + ${({ isFinalStatsType, theme }) => (!isFinalStatsType + ? css` + background-image: linear-gradient( + to right, + ${theme.colors.white} 33.333%, + ${theme.colors.black} 33.333%, + ${theme.colors.black} 66.666%, + ${theme.colors.white} 66.666%, + ${theme.colors.white} 72%, + ${theme.colors.black} 72%, + ${theme.colors.black} 100%) + ` + : css` + border-color: transparent; + background-image: linear-gradient( + to right, + ${theme.colors.white} 33.333%, + ${theme.colors.black} 33.333%, + ${theme.colors.black} 38%, + ${theme.colors.white} 38%, + ${theme.colors.white} 66.666%, + ${theme.colors.black} 66.666%, + ${theme.colors.black} 72%, + ${theme.colors.white} 72%, + ${theme.colors.white} 100%) + ` + )} +` diff --git a/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx b/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx deleted file mode 100644 index 7696654c..00000000 --- a/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { useCallback, useMemo } from 'react' - -import { format } from 'date-fns' - -import { parseDate } from 'helpers/parseDate' - -import { WeekDay, Wrapper } from './styled' - -export type Props = { - isInitialDateHidden: boolean, - matchDates: Array, - onDateClick: (date: string) => void, - profileDate: string, - selectedDate: string, -} - -export const VideoDate = (props: Props) => { - const { - isInitialDateHidden, - matchDates, - onDateClick, - profileDate, - selectedDate, - } = props - - const selectedDateIndex = useMemo(() => ( - matchDates.findIndex((date) => date === selectedDate) - ), [matchDates, selectedDate]) - - const lastDateIndex = matchDates.length - 1 - - const initialDateIndex = useMemo(() => ( - matchDates.findIndex((date) => date === profileDate) - ), [matchDates, profileDate]) - - const currentDay = useMemo(() => ( - matchDates.length && !(isInitialDateHidden && selectedDateIndex === initialDateIndex) - ? matchDates[selectedDateIndex] - : null - ), [initialDateIndex, isInitialDateHidden, matchDates, selectedDateIndex]) - - const previousDay = useMemo(() => { - if (selectedDateIndex !== 0) { - if (isInitialDateHidden && selectedDateIndex - 1 === initialDateIndex) { - return selectedDateIndex - 1 !== lastDateIndex ? matchDates[selectedDateIndex - 2] : null - } - return matchDates[selectedDateIndex - 1] - } - return null - }, [initialDateIndex, isInitialDateHidden, lastDateIndex, matchDates, selectedDateIndex]) - - const nextDay = useMemo(() => { - if (selectedDateIndex !== lastDateIndex) { - if (isInitialDateHidden && selectedDateIndex + 1 === initialDateIndex) { - return selectedDateIndex + 1 !== lastDateIndex ? matchDates[selectedDateIndex + 2] : null - } - return matchDates[selectedDateIndex + 1] - } - return null - }, [initialDateIndex, isInitialDateHidden, lastDateIndex, matchDates, selectedDateIndex]) - - const onDayClick = (date: string) => { - onDateClick?.(date) - } - - const formatDate = useCallback((date: string) => ( - format(parseDate(date, 'yyyy-MM-dd'), 'MMM dd, EE') - ), []) - - return ( - - {previousDay && ( - onDayClick(previousDay)} - >{formatDate(previousDay)} - - )} - {currentDay && ( - {formatDate(currentDay)} - - )} - {nextDay && ( - onDayClick(nextDay)} - >{formatDate(nextDay)} - - )} - - ) -} diff --git a/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx b/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx deleted file mode 100644 index 1a07d61d..00000000 --- a/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import styled, { css } from 'styled-components/macro' - -import { isMobileDevice } from 'config/userAgent' - -export const Wrapper = styled.div` - color: #FFFFFF; - display: flex; - justify-content: center; - align-items: center; - margin-bottom: 10px; - - > :not(:last-child) { - margin-right: 20px; - } - - ${isMobileDevice ? css` - @media screen and (orientation: landscape){ - > :not(:last-child) { - margin-right: 3px; - } - } - ` : ''} -` - -export const WeekDay = styled.div.attrs(() => ({ - 'aria-hidden': true, -}))<{isActive?: boolean}>` - position: relative; - color: rgba(255, 255, 255, 0.5); - font-size: 12px; - white-space: nowrap; - padding: 5px; - cursor: pointer; - - ${({ isActive }) => ( - isActive - ? css` - color: #FFFFFF; - cursor: default; - - :after { - position: absolute; - bottom: 0; - left: 0; - content: ''; - width: 100%; - height: 2px; - background-color: #FFFFFF; - } - ` - : '')} -` diff --git a/src/features/MatchSidePlaylists/components/TabVideo/styled.tsx b/src/features/MatchSidePlaylists/components/TabVideo/styled.tsx deleted file mode 100644 index 8ac7b73e..00000000 --- a/src/features/MatchSidePlaylists/components/TabVideo/styled.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import styled, { css } from 'styled-components/macro' -import { customScrollbar } from 'features/Common' -import { isMobileDevice } from '../../../../config/userAgent' - -export const MatchesWrapper = styled.div<{hasScroll?: boolean}>` - overflow-y: auto; - max-height: calc(100vh - 170px); - padding-right: ${({ hasScroll }) => (hasScroll ? '10px' : '')}; - - > * { - :not(:last-child) { - margin-bottom: 10px; - } - } - - ${customScrollbar} - - ${isMobileDevice ? css` - overflow: hidden; - max-height: initial; - ` : ''} -` diff --git a/src/features/MatchSidePlaylists/components/TabWatch/index.tsx b/src/features/MatchSidePlaylists/components/TabWatch/index.tsx index d6b94831..8c7ab360 100644 --- a/src/features/MatchSidePlaylists/components/TabWatch/index.tsx +++ b/src/features/MatchSidePlaylists/components/TabWatch/index.tsx @@ -1,21 +1,30 @@ -import { Fragment } from 'react' +import { + Fragment, + useMemo, + useRef, +} from 'react' -import isEmpty from 'lodash/isEmpty' import size from 'lodash/size' +import filter from 'lodash/filter' -import type { PlaylistOption, Playlists } from 'features/MatchPage/types' +import type { + PlaylistOption, + Playlists, + TournamentData, +} from 'features/MatchPage/types' import type { MatchInfo } from 'requests' import { DropdownSection } from '../DropdownSection' -import { MatchPlaylists } from '../MatchPlaylists' +import { MatchPlaylists, LIST_INDENT } from '../MatchPlaylists' import { SideInterviews } from '../SideInterviews' -import { PlayersPlaylists } from '../PlayersPlaylists' +import { Matches } from '../Matches' type Props = { onSelect: (option: PlaylistOption) => void, playlists: Playlists, profile: MatchInfo, selectedPlaylist?: PlaylistOption, + tournamentData: TournamentData, } export const TabWatch = ({ @@ -23,31 +32,44 @@ export const TabWatch = ({ playlists, profile, selectedPlaylist, -}: Props) => ( - - - - { + const matchPlaylistsRef = useRef(null) + + const additionalScrollHeight = (matchPlaylistsRef.current?.clientHeight || 0) + LIST_INDENT + + const filteredPlayListByDuration = useMemo(() => ( + filter(playlists.match, (playlist) => ( + profile?.live + ? Boolean(playlist.duration) || (playlist.id === 'full_game') + : Boolean(playlist.duration) + )) + ), [playlists.match, profile?.live]) + + return ( + + - - {!isEmpty(playlists.players.team1) && ( - + + + - )} - -) + + ) +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/Cell.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/Cell.tsx new file mode 100644 index 00000000..4f0a183d --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/Cell.tsx @@ -0,0 +1,176 @@ +import { Fragment, useRef } from 'react' +import { useQueryClient } from 'react-query' + +import isNumber from 'lodash/isNumber' + +import { KEYBOARD_KEYS, querieKeys } from 'config' + +import type { + Param, + TeamStatItem, + MatchScore, +} from 'requests' +import { getStatsEvents } from 'requests' + +import { usePageParams, useEventListener } from 'hooks' + +import { getHalfTime } from 'features/MatchPage/helpers/getHalfTime' +import { useMatchPageStore } from 'features/MatchPage/store' + +import { StatsType } from '../TabStats/config' +import { CircleAnimationBar } from '../CircleAnimationBar' + +import { + CellContainer, + ParamValueContainer, + ParamValue, + Divider, +} from './styled' + +type CellProps = { + teamId: number, + teamStatItem: TeamStatItem | null, +} + +export const Cell = ({ + teamId, + teamStatItem, +}: CellProps) => { + const paramValueContainerRef = useRef(null) + + const { profileId, sportType } = usePageParams() + + const { + playingData, + playingProgress, + playStatsEpisodes, + profile, + setIsPlayingFiltersEpisodes, + setPlayingData, + setWatchAllEpisodesTimer, + statsType, + watchAllEpisodesTimer, + } = useMatchPageStore() + + const client = useQueryClient() + + const matchScore = client.getQueryData(querieKeys.matchScore) + + const isClickable = (param: Param) => ( + Boolean(param.val) && param.clickable + ) + + const getDisplayedValue = (val: number | null) => ( + isNumber(val) ? String(val) : '-' + ) + + const onParamClick = async (param: Param) => { + if (!isClickable(param)) return + + const videoBounds = matchScore?.video_bounds || profile?.video_bounds + + setWatchAllEpisodesTimer(false) + setIsPlayingFiltersEpisodes(false) + + setPlayingData({ + player: { + id: null, + paramId: null, + }, + team: { + id: teamId, + paramId: param.id, + }, + }) + + try { + const events = await getStatsEvents({ + matchId: profileId, + paramId: param.id, + sportType, + teamId, + ...(statsType === StatsType.CURRENT_STATS && videoBounds && ( + getHalfTime(videoBounds, playingProgress) + )), + }) + + playStatsEpisodes(events) + // eslint-disable-next-line no-empty + } catch (e) {} + } + + useEventListener({ + callback: (e) => { + if (e.key !== KEYBOARD_KEYS.Enter || !teamStatItem) return + + const paramId = Number((e.target as HTMLElement).dataset.paramId) + + const param = paramId && (teamStatItem.param1.id === paramId + ? teamStatItem.param1 + : teamStatItem.param2) + + param && onParamClick(param) + }, + event: 'keydown', + target: paramValueContainerRef, + }) + + if (!teamStatItem) return null + + return ( + + + {watchAllEpisodesTimer + && playingData.team.paramId === teamStatItem.param1.id + && playingData.team.id === teamId + ? ( + + + + ) + : ( + onParamClick(teamStatItem.param1)} + data-param-id={teamStatItem.param1.id} + hasValue={Boolean(teamStatItem.param1.val)} + > + {getDisplayedValue(teamStatItem.param1.val)} + + )} + + {teamStatItem.param2 && ( + + {watchAllEpisodesTimer + && playingData.team.paramId === teamStatItem.param2.id + && playingData.team.id === teamId + ? ( + + + + ) + : ( + + / + onParamClick(teamStatItem.param2!)} + data-param-id={teamStatItem.param2.id} + hasValue={Boolean(teamStatItem.param2.val)} + > + {getDisplayedValue(teamStatItem.param2.val)} + + + )} + + )} + + + ) +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx new file mode 100644 index 00000000..035d2eee --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx @@ -0,0 +1,31 @@ +import { useEffect } from 'react' + +import find from 'lodash/find' + +import { useMatchPageStore } from 'features/MatchPage/store' + +export const useTeamsStatsTable = () => { + const { + plaingOrder, + profile, + setCircleAnimation, + teamsStats, + } = useMatchPageStore() + + const getStatItemById = (paramId: number) => { + if (!profile) return null + + return find(teamsStats[profile?.team2.id], ({ param1 }) => param1.id === paramId) || null + } + + useEffect(() => { + setCircleAnimation((state) => ({ + ...state, + plaingOrder, + })) + }, [setCircleAnimation, plaingOrder]) + + return { + getStatItemById, + } +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx new file mode 100644 index 00000000..a67c3284 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx @@ -0,0 +1,90 @@ +import map from 'lodash/map' + +import { useMatchPageStore } from 'features/MatchPage/store' +import { useLexicsStore } from 'features/LexicsStore' +import { Loader } from 'features/Loader' +import { defaultTheme } from 'features/Theme/config' + +import { useTeamsStatsTable } from './hooks' +import { Cell } from './Cell' +import { + Container, + TableWrapper, + Table, + Header, + Row, + CellContainer, + TeamShortName, + StatItemTitle, +} from './styled' + +export const TeamsStatsTable = () => { + const { + isTeamsStatsFetching, + profile, + teamsStats, + } = useMatchPageStore() + + const { getStatItemById } = useTeamsStatsTable() + + const { shortSuffix } = useLexicsStore() + + if (!profile) return null + + if (isTeamsStatsFetching) { + return ( + + ) + } + + return ( + + + +
+ + + + + + + + + +
+ + + {map(teamsStats[profile.team1.id], (team1StatItem) => { + const team2StatItem = getStatItemById(team1StatItem.param1.id) + const statItemTitle = team1StatItem[`name_${shortSuffix}`] + + return ( + + + + + {statItemTitle} + + + + + ) + })} + +
+
+
+ ) +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx new file mode 100644 index 00000000..42b6713c --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx @@ -0,0 +1,126 @@ +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config' + +import { Name } from 'features/Name' +import { customScrollbar } from 'features/Common' + +export const Container = styled.div`` + +export const TableWrapper = styled.div` + width: 100%; + overflow: auto; + font-size: 11px; + clip-path: inset(0 0 0 0 round 5px); + background-color: #333333; + + ${isMobileDevice + ? '' + : css` + max-height: calc(100vh - 203px); + `}; + + ${customScrollbar} +` + +export const Table = styled.table` + width: 100%; + border-spacing: 0; + border-collapse: collapse; + letter-spacing: -0.078px; + table-layout: fixed; +` + +export const TeamShortName = styled(Name)` + color: ${({ theme }) => theme.colors.white}; + letter-spacing: -0.078px; + text-transform: uppercase; + font-weight: 600; +` + +export const CellContainer = styled.td` + height: 45px; + border-bottom: 0.5px solid #5C5C5C; + background-color: #333333; + + :nth-child(2) { + text-align: center; + } + + :first-child, :last-child { + width: 32px; + } + + :first-child { + padding-left: 12px; + } + + :last-child { + text-align: right; + padding-right: 12px; + } +` + +export const Row = styled.tr` + :last-child:not(:first-child) { + ${CellContainer} { + border-bottom: none; + } + } +` + +export const Header = styled.thead` + position: sticky; + top: 0; + z-index: 1; + + ${CellContainer} { + background-color: #292929; + border-bottom-color: ${({ theme }) => theme.colors.secondary}; + } +` + +export const ParamValueContainer = styled.div`` + +type TParamValue = { + clickable?: boolean, + hasValue?: boolean, +} + +export const ParamValue = styled.span.attrs(({ clickable }: TParamValue) => ({ + ...clickable && { tabIndex: 0 }, +}))` + display: inline-block; + width: 15px; + height: 15px; + text-align: center; + position: relative; + font-weight: ${({ clickable }) => (clickable ? 700 : 400)}; + color: ${({ clickable, theme }) => (clickable ? '#5EB2FF' : theme.colors.white)}; + + ${({ clickable }) => (clickable + ? css` + cursor: pointer; + ` + : '')} + + ${({ hasValue }) => (!hasValue + ? css` + color: rgba(255, 255, 255, 0.5); + ` + : '')} +` + +export const StatItemTitle = styled.span` + color: ${({ theme }) => theme.colors.white}; + letter-spacing: -0.078px; + text-transform: uppercase; + font-weight: 600; + opacity: 0.5; +` + +export const Divider = styled.span` + color: ${({ theme }) => theme.colors.white}; + opacity: 0.5; + font-weight: 600; +` diff --git a/src/features/MatchSidePlaylists/config.tsx b/src/features/MatchSidePlaylists/config.tsx index 9ab22db3..fc7219fd 100644 --- a/src/features/MatchSidePlaylists/config.tsx +++ b/src/features/MatchSidePlaylists/config.tsx @@ -1,5 +1,6 @@ export enum Tabs { WATCH, EVENTS, - VIDEO + STATS, + PLAYERS, } diff --git a/src/features/MatchSidePlaylists/hooks.tsx b/src/features/MatchSidePlaylists/hooks.tsx index a953144c..ee556c44 100644 --- a/src/features/MatchSidePlaylists/hooks.tsx +++ b/src/features/MatchSidePlaylists/hooks.tsx @@ -1,10 +1,7 @@ -import { - useEffect, - useMemo, - useState, -} from 'react' +import { useEffect, useMemo } from 'react' -import reduce from 'lodash/reduce' +import isEmpty from 'lodash/isEmpty' +import compact from 'lodash/compact' import { useMatchPageStore } from 'features/MatchPage/store' @@ -14,30 +11,41 @@ export const useMatchSidePlaylists = () => { const { closePopup, events, + isEmptyPlayersStats, matchPlaylists: playlists, - tournamentData, + profile: matchProfile, + selectedTab, + setSelectedTab, + teamsStats, } = useMatchPageStore() - const [selectedTab, setSelectedTab] = useState(Tabs.WATCH) - const isWatchTabVisible = useMemo(() => { - const playListFilter = reduce( - playlists.match, - (acc, item) => { - let result = acc - if (item.duration) result++ - return result - }, - 0, - ) - return playListFilter > 1 - }, [playlists]) + + const isWatchTabVisible = !matchProfile?.live || Number(matchProfile.c_match_calc_status) > 1 const isEventTabVisible = useMemo(() => ( events.length > 0 ), [events]) - const isVideoTabVisible = useMemo(() => ( - tournamentData.matches.length > 1 - ), [tournamentData]) + const isPlayersTabVisible = useMemo(() => ( + !isEmpty(playlists.players.team1) + ), [playlists.players.team1]) + + const isStatsTabVisible = useMemo(() => ( + !isEmpty(teamsStats) + || (matchProfile?.team1.id && !isEmptyPlayersStats(matchProfile.team1.id)) + || (matchProfile?.team2.id && !isEmptyPlayersStats(matchProfile.team2.id)) + ), [ + isEmptyPlayersStats, + matchProfile?.team1.id, + matchProfile?.team2.id, + teamsStats, + ]) + + const hasLessThanFourTabs = compact([ + isWatchTabVisible, + isEventTabVisible, + isPlayersTabVisible, + isStatsTabVisible, + ]).length < 4 useEffect(() => { switch (true) { @@ -47,19 +55,30 @@ export const useMatchSidePlaylists = () => { case isEventTabVisible: setSelectedTab(Tabs.EVENTS) break - case isVideoTabVisible: - setSelectedTab(Tabs.VIDEO) + case isPlayersTabVisible: + setSelectedTab(Tabs.PLAYERS) + break + case isStatsTabVisible: + setSelectedTab(Tabs.STATS) break } - }, [isEventTabVisible, isVideoTabVisible, isWatchTabVisible]) + }, [ + isEventTabVisible, + isPlayersTabVisible, + isStatsTabVisible, + isWatchTabVisible, + setSelectedTab, + ]) useEffect(() => { if (selectedTab !== Tabs.EVENTS) closePopup() }, [selectedTab, closePopup]) return { + hasLessThanFourTabs, isEventTabVisible, - isVideoTabVisible, + isPlayersTabVisible, + isStatsTabVisible, isWatchTabVisible, onTabClick: setSelectedTab, selectedTab, diff --git a/src/features/MatchSidePlaylists/index.tsx b/src/features/MatchSidePlaylists/index.tsx index b3a0edc9..bb67f45e 100644 --- a/src/features/MatchSidePlaylists/index.tsx +++ b/src/features/MatchSidePlaylists/index.tsx @@ -4,10 +4,7 @@ import { useState, } from 'react' -import type { TCircleAnimation, TSetCircleAnimation } from 'features/CircleAnimationBar' import type { PlaylistOption } from 'features/MatchPage/types' -import { Tab, TabsGroup } from 'features/Common' -import { T9n } from 'features/T9n' import { useMatchPageStore } from 'features/MatchPage/store' import { useEventListener } from 'hooks' @@ -17,47 +14,51 @@ import { isIOS } from 'config/userAgent' import { Tabs } from './config' import { TabEvents } from './components/TabEvents' import { TabWatch } from './components/TabWatch' -import { TabVideo } from './components/TabVideo' +import { TabPlayers } from './components/TabPlayers' +import { TabStats } from './components/TabStats' import { useMatchSidePlaylists } from './hooks' import { Wrapper, TabsWrapper, + TabsGroup, + Tab, + TabIcon, + TabTitle, Container, } from './styled' const tabPanes = { [Tabs.WATCH]: TabWatch, [Tabs.EVENTS]: TabEvents, - [Tabs.VIDEO]: TabVideo, + [Tabs.STATS]: TabStats, + [Tabs.PLAYERS]: TabPlayers, } type Props = { - circleAnimation?: TCircleAnimation, onSelect: (option: PlaylistOption) => void, selectedPlaylist?: PlaylistOption, - setCircleAnimation?: TSetCircleAnimation, } export const MatchSidePlaylists = ({ - circleAnimation, onSelect, selectedPlaylist, - setCircleAnimation, }: Props) => { const { hideProfileCard, matchPlaylists: playlists, profile, + selectedTab, showProfileCard, tournamentData, } = useMatchPageStore() const { + hasLessThanFourTabs, isEventTabVisible, - isVideoTabVisible, + isPlayersTabVisible, + isStatsTabVisible, isWatchTabVisible, onTabClick, - selectedTab, } = useMatchSidePlaylists() const TabPane = tabPanes[selectedTab] @@ -99,29 +100,41 @@ export const MatchSidePlaylists = ({ return ( - + {isWatchTabVisible ? ( onTabClick(Tabs.WATCH)} > - + + ) : null} {isEventTabVisible ? ( onTabClick(Tabs.EVENTS)} > - + + + + ) : null} + {isPlayersTabVisible ? ( + onTabClick(Tabs.PLAYERS)} + > + + ) : null} - {isVideoTabVisible ? ( + {isStatsTabVisible ? ( onTabClick(Tabs.VIDEO)} + aria-pressed={selectedTab === Tabs.STATS} + onClick={() => onTabClick(Tabs.STATS)} > - + + ) : null} @@ -130,11 +143,9 @@ export const MatchSidePlaylists = ({ ` + display: flex; + justify-content: center; + gap: ${isMobileDevice ? 30 : 20}px; + + ${({ hasLessThanFourTabs }) => (hasLessThanFourTabs ? css` - padding: 0 5px; - ` - : ''}; + padding-top: 10px; + + ${Tab} { + justify-content: center; + flex-direction: row; + gap: 5px; + } + + ${TabIcon} { + margin-bottom: 0; + } + ` + : '')} +` + +export const TabTitle = styled(T9n)` + font-size: 10px; + font-weight: 500; + text-transform: uppercase; + color: ${({ theme }) => theme.colors.white}; +` + +export const Tab = styled.button.attrs({ role: 'tab' })` + display: flex; + flex-direction: column; + justify-content: space-between; + align-items: center; + padding-left: 0; + padding-right: 0; + opacity: 0.4; + cursor: pointer; + border: none; + background: none; + + &[aria-pressed="true"], :hover { + opacity: 1; + + ${TabTitle} { + font-weight: 600; + } + } + + :only-child { + cursor: unset; + } +` + +type TabIconProps = { + icon: 'watch' | 'plays' | 'players' | 'stats', +} + +export const TabIcon = styled.div` + width: 22px; + height: 22px; + flex-shrink: 0; + margin-bottom: 5px; + background-image: url(/images/matchTabs/${({ icon }) => `${icon}.svg`}); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + + ${({ icon }) => (icon === 'players' + ? css` + background-size: 25px; + ` + : '')} ` type TContainer = { - forVideoTab?: boolean, + forWatchTab?: boolean, hasScroll: boolean, } @@ -37,14 +113,13 @@ export const Container = styled.div` width: 320px; margin-top: 14px; max-height: calc(100vh - 130px); - overflow-y: ${({ forVideoTab }) => (forVideoTab ? 'hidden' : 'auto')}; - padding-right: ${({ forVideoTab }) => (forVideoTab ? '0' : '')}; + overflow-y: ${({ forWatchTab }) => (forWatchTab ? 'hidden' : 'auto')}; + padding-right: ${({ forWatchTab }) => (forWatchTab ? '0' : '')}; padding-left: 14px; padding-right: ${({ hasScroll }) => (hasScroll ? '10px' : '')}; ${customScrollbar} - @media ${devices.tablet} { margin-top: 15px; } @@ -52,6 +127,7 @@ export const Container = styled.div` ${isMobileDevice ? css` padding: 0 5px; + padding-bottom: ${isIOS ? 60 : 78}px; overflow-y: hidden; max-height: initial; diff --git a/src/features/MultiSourcePlayer/hooks/index.tsx b/src/features/MultiSourcePlayer/hooks/index.tsx index 37097c1d..8421f4ac 100644 --- a/src/features/MultiSourcePlayer/hooks/index.tsx +++ b/src/features/MultiSourcePlayer/hooks/index.tsx @@ -7,12 +7,12 @@ import { import size from 'lodash/size' -import type { TSetCircleAnimation } from 'features/CircleAnimationBar' import { useControlsVisibility } from 'features/StreamPlayer/hooks/useControlsVisibility' import { useFullscreen } from 'features/StreamPlayer/hooks/useFullscreen' import { useVolume } from 'features/VideoPlayer/hooks/useVolume' import { useNoNetworkPopupStore } from 'features/NoNetworkPopup' import { useMatchPageStore } from 'features/MatchPage/store' +import { FULL_GAME_KEY } from 'features/MatchPage/helpers/buildPlaylists' import { useEventListener, @@ -20,6 +20,7 @@ import { useObjectState, usePageParams, } from 'hooks' +import type { SetPartialState } from 'hooks' import { MatchInfo, @@ -27,6 +28,8 @@ import { VIEW_INTERVAL_MS, } from 'requests' +import type { PausedData } from 'features/MatchPage/components/FinishedMatch/hooks' + import { useProgressChangeHandler } from './useProgressChangeHandler' import { usePlayingHandlers } from './usePlayingHandlers' import { useVideoQuality } from './useVideoQuality' @@ -58,19 +61,26 @@ export type Props = { isOpenPopup?: boolean, onError?: () => void, onPlayingChange: (playing: boolean) => void, + pausedData: PausedData, profile: MatchInfo, - setCircleAnimation: TSetCircleAnimation, + setPausedData: SetPartialState, } export const useMultiSourcePlayer = ({ chapters, onError, onPlayingChange, - setCircleAnimation, + pausedData, + setPausedData, }: Props) => { const { + handlePlaylistClick, isPlayFilterEpisodes, + matchPlaylists, playNextEpisode, + selectedPlaylist, + setCircleAnimation, + setPlayingProgress, } = useMatchPageStore() const { profileId, sportType } = usePageParams() @@ -202,8 +212,40 @@ export const useMultiSourcePlayer = ({ timeForStatistics.current = (value + chapter.startMs) / 1000 setPlayerState({ playedProgress: value }) + setPlayingProgress(Math.floor(value / 1000)) + + if (chapter.isFullMatchChapter) { + setPausedData({ + activeChapterIndex, + activePlayer, + playedProgress: value, + }) + } } + const backToPausedTime = useCallback(() => { + if (selectedPlaylist?.id !== FULL_GAME_KEY) { + handlePlaylistClick(matchPlaylists.match[0]) + } + + setTimeout(() => { + setPlayerState((state) => ({ + activeChapterIndex: pausedData.activeChapterIndex, + playedProgress: pausedData.playedProgress, + seek: { + ...state.seek, + [pausedData.activePlayer]: pausedData.playedProgress / 1000, + }, + })) + }, 0) + }, [ + selectedPlaylist?.id, + pausedData, + handlePlaylistClick, + matchPlaylists.match, + setPlayerState, + ]) + const onEnded = () => { playNextChapter() } @@ -248,7 +290,11 @@ export const useMultiSourcePlayer = ({ playNextEpisode() } if (playedProgress >= chapterDuration && !seeking && !isPlayFilterEpisodes) { - playNextChapter() + if (isLastChapterPlaying) { + backToPausedTime() + } else { + playNextChapter() + } } }, [ isPlayFilterEpisodes, @@ -258,6 +304,8 @@ export const useMultiSourcePlayer = ({ seeking, playNextChapter, setPlayerState, + isLastChapterPlaying, + backToPausedTime, ]) useEventListener({ diff --git a/src/features/MultiSourcePlayer/types.tsx b/src/features/MultiSourcePlayer/types.tsx index 50c6ab29..e1efe5dd 100644 --- a/src/features/MultiSourcePlayer/types.tsx +++ b/src/features/MultiSourcePlayer/types.tsx @@ -4,6 +4,7 @@ export type Chapter = { duration: number, endMs: number, endOffsetMs: number, + isFullMatchChapter?: boolean, period: number, startMs: number, startOffsetMs: number, diff --git a/src/features/Name/index.tsx b/src/features/Name/index.tsx index a2099b82..5f22a50f 100644 --- a/src/features/Name/index.tsx +++ b/src/features/Name/index.tsx @@ -12,6 +12,7 @@ export type ObjectWithName = { type Props = { className?: string, + id?: string, nameObj: ObjectWithName, prefix?: string, } @@ -44,9 +45,17 @@ export const useName = ( export const Name = ({ className, + id, nameObj, prefix, }: Props) => { const name = useName(nameObj, prefix) - return {name} + return ( + + {name} + + ) } diff --git a/src/features/StreamPlayer/hooks/index.tsx b/src/features/StreamPlayer/hooks/index.tsx index 7702f9ef..e1fd1049 100644 --- a/src/features/StreamPlayer/hooks/index.tsx +++ b/src/features/StreamPlayer/hooks/index.tsx @@ -21,12 +21,12 @@ import { useInterval, } from 'hooks' -import type { TSetCircleAnimation } from 'features/CircleAnimationBar' import type { Chapters } from 'features/StreamPlayer/types' import { useVolume } from 'features/VideoPlayer/hooks/useVolume' import { useNoNetworkPopupStore } from 'features/NoNetworkPopup' import { useLiveMatch } from 'features/MatchPage/components/LiveMatch/hooks' import { useLexicsStore } from 'features/LexicsStore' +import { useMatchPageStore } from 'features/MatchPage/store' import { VIEW_INTERVAL_MS, saveMatchStats } from 'requests' @@ -65,7 +65,6 @@ export type Props = { onPlayingChange: (playing: boolean) => void, onProgressChange: (seconds: number) => void, resumeFrom?: number, - setCircleAnimation: TSetCircleAnimation, url?: string, } @@ -76,7 +75,6 @@ export const useVideoPlayer = ({ onPlayingChange, onProgressChange: progressChangeCallback, resumeFrom, - setCircleAnimation, }: Props) => { const [{ activeChapterIndex, @@ -91,14 +89,17 @@ export const useVideoPlayer = ({ seeking, }, setPlayerState] = useObjectState({ ...initialState, chapters: chaptersProps }) + const { onPlaylistSelect } = useLiveMatch() + const { lang } = useLexicsStore() + const { profileId, sportType } = usePageParams() const { isPlayFilterEpisodes, - onPlaylistSelect, + matchPlaylists, playNextEpisode, selectedPlaylist, - } = useLiveMatch() - const { lang } = useLexicsStore() - const { profileId, sportType } = usePageParams() + setCircleAnimation, + setPlayingProgress, + } = useMatchPageStore() /** время для сохранения статистики просмотра матча */ const timeForStatistics = useRef(0) @@ -129,13 +130,7 @@ export const useVideoPlayer = ({ } = usePlayingHandlers(setPlayerState, chapters) const restartVideo = () => { - onPlaylistSelect({ - duration: 0, - episodes: [], - id: FULL_GAME_KEY, - lexic: 13028, - type: 0, - }) + onPlaylistSelect(matchPlaylists.match[0]) } const getActiveChapter = useCallback( @@ -238,6 +233,7 @@ export const useVideoPlayer = ({ setPlayerState({ playedProgress: value }) timeForStatistics.current = (value + chapter.startMs) / 1000 + setPlayingProgress(Math.floor(value / 1000)) progressChangeCallback(value / 1000) } @@ -258,6 +254,7 @@ export const useVideoPlayer = ({ onPlaylistSelect, selectedPlaylist, setPlayerState, + matchPlaylists.match, ]) const backToPausedTime = useCallback(() => { @@ -277,11 +274,12 @@ export const useVideoPlayer = ({ onPlaylistSelect, selectedPlaylist, setPlayerState, + matchPlaylists.match, ]) useEffect(() => { if (chapters[0]?.isFullMatchChapter) { - setPausedProgress(playedProgress) + setPausedProgress(playedProgress + chapters[0].startOffsetMs) } // eslint-disable-next-line }, [selectedPlaylist]) @@ -358,11 +356,11 @@ export const useVideoPlayer = ({ ]) useEffect(() => { - if ((isLive && chapters[0]?.isFullMatchChapter) || isEmpty(chapters)) return + if ((chapters[0]?.isFullMatchChapter) || isEmpty(chapters)) return const { duration: chapterDuration } = getActiveChapter() if (playedProgress >= chapterDuration && !seeking && !isPlayFilterEpisodes) { - if (isLive && isLastChapterPlaying) { + if (isLastChapterPlaying) { backToPausedTime() } else { playNextChapter() @@ -417,7 +415,7 @@ export const useVideoPlayer = ({ useEffect(() => { if (!navigator.serviceWorker || !isIOS) return undefined - const listener = (event: MessageEvent) => { + const listener = (event: MessageEvent<{ duration: number }>) => { setPlayerState({ duration: toMilliSeconds(event.data.duration) }) } @@ -437,7 +435,6 @@ export const useVideoPlayer = ({ }, [ready, videoRef]) useEffect(() => { - if (!setCircleAnimation) return setCircleAnimation((state) => ({ ...state, playedProgress, diff --git a/src/features/T9n/index.tsx b/src/features/T9n/index.tsx index 85140c4f..35c3ea48 100644 --- a/src/features/T9n/index.tsx +++ b/src/features/T9n/index.tsx @@ -7,6 +7,7 @@ const Text = styled.span`` type Props = { className?: string, + id?: string, onClick?: () => void, t: LexicsId, values?: Values, @@ -14,6 +15,7 @@ type Props = { export const T9n = ({ className, + id, onClick, t, values, @@ -22,6 +24,7 @@ export const T9n = ({ return ( diff --git a/src/features/Tooltip/index.tsx b/src/features/Tooltip/index.tsx index b0683b49..b599709d 100644 --- a/src/features/Tooltip/index.tsx +++ b/src/features/Tooltip/index.tsx @@ -25,7 +25,7 @@ export const TooltipWrapper = styled(TooltipBlockWrapper)` ` type Props = { - children: ReactNode, + children?: ReactNode, lexic: string, } @@ -34,7 +34,10 @@ export const Tooltip = ({ lexic, }: Props) => ( - + {children} diff --git a/src/helpers/getTeamAbbr/index.tsx b/src/helpers/getTeamAbbr/index.tsx new file mode 100644 index 00000000..62a1eb51 --- /dev/null +++ b/src/helpers/getTeamAbbr/index.tsx @@ -0,0 +1,25 @@ +import toUpper from 'lodash/toUpper' +import split from 'lodash/split' +import size from 'lodash/size' + +import pipe from 'lodash/fp/pipe' +import take from 'lodash/fp/take' +import join from 'lodash/fp/join' +import map from 'lodash/fp/map' + +export const getTeamAbbr = (teamName: string) => { + const nameParts = split(teamName, ' ') + + return size(nameParts) > 1 + ? pipe( + map(take(1)), + join(''), + toUpper, + )(nameParts) + + : pipe( + take(3), + join(''), + toUpper, + )(nameParts[0]) +} diff --git a/src/helpers/index.tsx b/src/helpers/index.tsx index 1a15820a..f1e5376d 100644 --- a/src/helpers/index.tsx +++ b/src/helpers/index.tsx @@ -10,3 +10,4 @@ export * from './getRandomString' export * from './selectedApi' export * from './openSubscribePopup' export * from './getCurrentYear' +export * from './getTeamAbbr' diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index 8219ac0e..6e2f4ec7 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -5,3 +5,5 @@ export * from './useInterval' export * from './useEventListener' export * from './useObjectState' export * from './usePageParams' +export * from './useTooltip' +export * from './useModalRoot' diff --git a/src/hooks/useModalRoot.tsx b/src/hooks/useModalRoot.tsx new file mode 100644 index 00000000..ecf3144f --- /dev/null +++ b/src/hooks/useModalRoot.tsx @@ -0,0 +1,5 @@ +import { useRef } from 'react' + +export const MODAL_ROOT_ID = 'modal-root' + +export const useModalRoot = () => useRef(document.getElementById(MODAL_ROOT_ID)) diff --git a/src/hooks/usePageParams.tsx b/src/hooks/usePageParams.tsx index 4810663c..4d1df1c7 100644 --- a/src/hooks/usePageParams.tsx +++ b/src/hooks/usePageParams.tsx @@ -22,6 +22,7 @@ export const usePageParams = () => { return { profileId: Number(pageId), profileType: ProfileTypes[toUpper(profileName) as keyof typeof ProfileTypes], + sportName, sportType: SportTypes[toUpper(sportName) as keyof typeof SportTypes], } } diff --git a/src/hooks/useTooltip.tsx b/src/hooks/useTooltip.tsx new file mode 100644 index 00000000..e2c236e6 --- /dev/null +++ b/src/hooks/useTooltip.tsx @@ -0,0 +1,89 @@ +import type { CSSProperties, MouseEvent } from 'react' +import { useState } from 'react' + +import isUndefined from 'lodash/isUndefined' + +import { useToggle } from './useToggle' + +type TooltipParams = { + anchorId?: string, + horizontalPosition?: 'left' | 'center' | 'right', + indent?: number, + tooltipText: string, + verticalPosition?: 'top' | 'bottom', +} + +export const useTooltip = () => { + const [stateTooltipStyle, setTooltipStyle] = useState({}) + const [stateAnchorId, setAnchorId] = useState(null) + const [stateTooltipText, setTooltipText] = useState('') + + const { + close: hideTooltip, + isOpen: isTooltipShown, + open: showTooltip, + } = useToggle() + + const onMouseOver = ({ + anchorId, + horizontalPosition = 'center', + indent = 10, + tooltipText, + verticalPosition = 'bottom', + }: TooltipParams) => (e: MouseEvent) => { + const target = e.target as HTMLElement + + if (anchorId && target.id !== anchorId) return + + const { + left, + right, + top, + } = target.getBoundingClientRect() + + const coords: Partial = { + top: verticalPosition === 'bottom' + ? top + target.clientHeight + indent + : top - target.clientHeight - indent, + + ...(horizontalPosition === 'center' && { left: left + target.clientWidth / 2 }), + ...(horizontalPosition === 'left' && { left }), + ...(horizontalPosition === 'right' && { right }), + } + + const tooltipStyle: CSSProperties = { + left: !isUndefined(coords.left) ? `${coords.left}px` : 'auto', + position: 'fixed', + right: !isUndefined(coords.right) ? `${window.screen.width - coords.right}px` : 'auto', + top: `${coords.top}px`, + zIndex: 999, + + ...(horizontalPosition === 'center' && { transform: 'translateX: (-50%)' }), + ...(verticalPosition === 'top' && { transform: 'translateY: (-50%)' }), + ...(horizontalPosition === 'center' && verticalPosition === 'top' && { transform: 'translate: (-50%, -50%)' }), + } + + if (anchorId) { + setAnchorId(anchorId) + } + + setTooltipStyle(tooltipStyle) + showTooltip() + setTooltipText(tooltipText) + } + + const onMouseLeave = () => { + hideTooltip() + setAnchorId(null) + setTooltipStyle({}) + } + + return { + anchorId: stateAnchorId, + isTooltipShown, + onMouseLeave, + onMouseOver, + tooltipStyle: stateTooltipStyle, + tooltipText: stateTooltipText, + } +} diff --git a/src/requests/getMatchEvents.tsx b/src/requests/getMatchEvents.tsx index 51af4761..f5ffea5b 100644 --- a/src/requests/getMatchEvents.tsx +++ b/src/requests/getMatchEvents.tsx @@ -18,6 +18,7 @@ type Args = { type PlayerNames = { name_eng: string, name_rus: string, + num?: number, } export type Event = Episode & { diff --git a/src/requests/getMatchInfo.tsx b/src/requests/getMatchInfo.tsx index 6c492a80..8ce9b456 100644 --- a/src/requests/getMatchInfo.tsx +++ b/src/requests/getMatchInfo.tsx @@ -17,6 +17,7 @@ export type Team = { name_eng: string, name_rus: string, score: number, + shirt_color: string | null, } export type MatchTournament = { @@ -32,10 +33,19 @@ export type VideoBound = { s: string, } -type VideoBounds = Array +export type VideoBounds = Array + +export enum MatchStatuses { + Upcoming = 1, + Active, + Timeout, + Finished, + Parsed, +} export type MatchInfo = { access?: boolean, + c_match_calc_status: MatchStatuses | null, calc: boolean, country: TournamentType, country_id: number, diff --git a/src/requests/getMatchParticipants.tsx b/src/requests/getMatchParticipants.tsx new file mode 100644 index 00000000..41848848 --- /dev/null +++ b/src/requests/getMatchParticipants.tsx @@ -0,0 +1,69 @@ +import isUndefined from 'lodash/isUndefined' + +import { SportTypes, STATS_API_URL } from 'config' + +import { callApi } from 'helpers' + +export type Player = { + birthday: string | null, + c_country: number, + c_gender: number, + club_f_team: number, + club_shirt_num: number, + firstname_eng: string, + firstname_national: string | null, + firstname_rus: string, + height: number | null, + id: number, + is_gk: boolean, + lastname_eng: string, + lastname_national: string | null, + lastname_rus: string, + national_f_team: number | null, + national_shirt_num: number, + nickname_eng: string | null, + nickname_rus: string | null, + num: number | null, + ord: number, + weight: number | null, +} + +type DataItem = { + players: Array, + team_id: number, +} + +type Response = { + data?: Array, + error?: { + code: string, + message: string, + }, +} + +type GetMatchParticipantsArgs = { + matchId: number, + period?: number, + second?: number, + sportType: SportTypes, +} + +export const getMatchParticipants = async ({ + matchId, + period, + second, + sportType, +}: GetMatchParticipantsArgs) => { + const config = { + method: 'GET', + } + + const response: Response = await callApi({ + config, + url: `${STATS_API_URL}/ask/participants?sport_id=${sportType}&match_id=${matchId}${isUndefined(second) ? '' : `&second=${second}&half=${period}`}`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || []) +} diff --git a/src/requests/getMatchScore.tsx b/src/requests/getMatchScore.tsx index d46a5dff..523e965a 100644 --- a/src/requests/getMatchScore.tsx +++ b/src/requests/getMatchScore.tsx @@ -2,14 +2,18 @@ import { callApi } from 'helpers' import { API_ROOT } from 'config' -import type { Team, MatchTournament } from 'requests/getMatchInfo' +import type { + Team, + MatchTournament, + VideoBounds, +} from 'requests/getMatchInfo' type Params = { profileId: number, sportType: number, } -type Response = { +export type MatchScore = { match_date: string, match_date_utc: string, match_id: number, @@ -17,9 +21,10 @@ type Response = { team1: Team, team2: Team, tournament: MatchTournament, + video_bounds: VideoBounds | null, } -export const getMatchScore = ({ profileId, sportType }: Params): Promise => { +export const getMatchScore = ({ profileId, sportType }: Params): Promise => { const url = `${API_ROOT}/v1/matches/${sportType}/${profileId}/scores` const config = { diff --git a/src/requests/getPlayersStats.tsx b/src/requests/getPlayersStats.tsx new file mode 100644 index 00000000..af898d49 --- /dev/null +++ b/src/requests/getPlayersStats.tsx @@ -0,0 +1,58 @@ +import isUndefined from 'lodash/isUndefined' + +import { callApi } from 'helpers' + +import { STATS_API_URL } from 'config' + +export type PlayerParam = { + clickable: boolean, + data_type: string, + id: number, + lexic: number, + lexica_short: number | null, + markers: Array | null, + name_en: string, + name_ru: string, + val: number | null, +} + +export type PlayersStats = { + [playerId: string]: { + [paramId: string]: PlayerParam, + }, +} + +type Response = { + data?: PlayersStats, + error?: string, + message?: string, +} + +type GetPlayersStatsArgs = { + matchId: number, + period?: number, + second?: number, + sportName: string, + teamId: number, +} + +export const getPlayersStats = async ({ + matchId, + period, + second, + sportName, + teamId, +}: GetPlayersStatsArgs) => { + const config = { + method: 'GET', + } + + const response: Response = await callApi({ + config, + url: `${STATS_API_URL}/${sportName}/matches/${matchId}/teams/${teamId}/players/stats${isUndefined(second) ? '' : `?second=${second}&half=${period}`}`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || {}) +} diff --git a/src/requests/getStatsEvents.tsx b/src/requests/getStatsEvents.tsx new file mode 100644 index 00000000..f6a40344 --- /dev/null +++ b/src/requests/getStatsEvents.tsx @@ -0,0 +1,57 @@ +import { SportTypes, STATS_API_URL } from 'config' + +import { callApi } from 'helpers' + +import { Episodes } from './getMatchPlaylists' + +type Response = { + data?: Episodes, + error?: { + code: string, + message: string, + }, +} + +type GetStatsEventsArgs = { + matchId: number, + paramId: number, + period?: number, + playerId?: number, + second?: number, + sportType: SportTypes, + teamId: number, +} + +export const getStatsEvents = async ({ + matchId, + paramId, + period, + playerId, + second, + sportType, + teamId, +}: GetStatsEventsArgs) => { + const config = { + body: { + half: period, + match_id: matchId, + match_second: second, + offset_end: 6, + offset_start: 6, + option_id: 0, + param_id: paramId, + player_id: playerId, + sport_id: sportType, + team_id: teamId, + }, + } + + const response: Response = await callApi({ + config, + url: `${STATS_API_URL}/video`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || []) +} diff --git a/src/requests/getTeamsStats.tsx b/src/requests/getTeamsStats.tsx new file mode 100644 index 00000000..ecf7db2f --- /dev/null +++ b/src/requests/getTeamsStats.tsx @@ -0,0 +1,60 @@ +import isUndefined from 'lodash/isUndefined' + +import { callApi } from 'helpers' + +import { STATS_API_URL } from 'config' + +export type Param = { + clickable: boolean, + data_type: string, + id: number, + lexic: number, + markers: Array, + name_en: string, + name_ru: string, + val: number | null, +} + +export type TeamStatItem = { + lexic: number, + name_en: string, + name_ru: string, + order: number, + param1: Param, + param2: Param | null, +} + +type Response = { + data?: { + [teamId: string]: Array, + }, + error?: string, + message?: string, +} + +type GetTeamsStatsArgs = { + matchId: number, + period?: number, + second?: number, + sportName: string, +} + +export const getTeamsStats = async ({ + matchId, + period, + second, + sportName, +}: GetTeamsStatsArgs) => { + const config = { + method: 'GET', + } + + const response: Response = await callApi({ + config, + url: `${STATS_API_URL}/${sportName}/matches/${matchId}/teams/stats?group_num=0${isUndefined(second) ? '' : `&second=${second}&half=${period}`}`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || {}) +} diff --git a/src/requests/index.tsx b/src/requests/index.tsx index b38cc8ae..d96b03e2 100644 --- a/src/requests/index.tsx +++ b/src/requests/index.tsx @@ -30,3 +30,7 @@ export * from './getGeoInfo' export * from './getTokenVirtualUser' export * from './getMatchScore' export * from './getLiveScores' +export * from './getTeamsStats' +export * from './getPlayersStats' +export * from './getMatchParticipants' +export * from './getStatsEvents'