From ae3e29bcf4b0082e418dac9d4e175fdb25314d20 Mon Sep 17 00:00:00 2001 From: Ruslan Khayrullin Date: Mon, 9 Jan 2023 15:55:42 +0500 Subject: [PATCH] feat(in-141): players stats --- src/features/LexicsStore/helpers/index.tsx | 4 + src/features/LexicsStore/hooks/index.tsx | 2 + src/features/MatchPage/store/hooks/index.tsx | 8 +- .../MatchPage/store/hooks/useMatchData.tsx | 19 +- .../MatchPage/store/hooks/usePlayersStats.tsx | 99 +++---- .../MatchPage/store/hooks/useStatsTab.tsx | 18 +- .../MatchPage/store/hooks/useTeamsStats.tsx | 34 ++- .../components/CircleAnimationBar/index.tsx | 16 ++ .../components/PlayersTable/Cell.tsx | 65 +++++ .../components/PlayersTable/config.tsx | 7 +- .../PlayersTable/hooks/usePlayers.tsx | 12 +- .../PlayersTable/hooks/useTable.tsx | 87 ++++-- .../components/PlayersTable/index.tsx | 194 +++++++------- .../components/PlayersTable/styled.tsx | 247 ++++++++++-------- .../components/TeamsStatsTable/index.tsx | 160 +++++++----- src/features/MatchSidePlaylists/index.tsx | 6 +- src/features/MatchSidePlaylists/styled.tsx | 31 +-- src/hooks/useModalRoot.tsx | 5 + src/hooks/useTooltip.tsx | 88 +++++++ src/requests/getTeamsStats.tsx | 4 +- 20 files changed, 736 insertions(+), 370 deletions(-) create mode 100644 src/features/MatchSidePlaylists/components/CircleAnimationBar/index.tsx create mode 100644 src/features/MatchSidePlaylists/components/PlayersTable/Cell.tsx create mode 100644 src/hooks/useModalRoot.tsx create mode 100644 src/hooks/useTooltip.tsx 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/MatchPage/store/hooks/index.tsx b/src/features/MatchPage/store/hooks/index.tsx index 938da6e2..84788943 100644 --- a/src/features/MatchPage/store/hooks/index.tsx +++ b/src/features/MatchPage/store/hooks/index.tsx @@ -79,15 +79,17 @@ export const useMatchPage = () => { events, handlePlaylistClick, isEmptyPlayersStats, + isPlayersStatsFetching, + isTeamsStatsFetching, matchPlaylists, playersData, playersStats, selectedPlaylist, setFullMatchPlaylistDuration, setPlayingProgress, - setStatsType, statsType, teamsStats, + toggleStatsType, } = useMatchData(matchProfile) const profile = matchProfile @@ -183,7 +185,9 @@ export const useMatchPage = () => { isLiveMatch, isOpenFiltersPopup, isPlayFilterEpisodes, + isPlayersStatsFetching, isStarted, + isTeamsStatsFetching, likeImage, likeToggle, matchPlaylists, @@ -203,7 +207,6 @@ export const useMatchPage = () => { setPlaingOrder, setPlayingProgress, setReversed, - setStatsType, setUnreversed, setWatchAllEpisodesTimer, showProfileCard, @@ -212,6 +215,7 @@ export const useMatchPage = () => { toggleActiveEvents, toggleActivePlayers, togglePopup, + toggleStatsType, tournamentData, watchAllEpisodesTimer, } diff --git a/src/features/MatchPage/store/hooks/useMatchData.tsx b/src/features/MatchPage/store/hooks/useMatchData.tsx index 39fbf3b5..e98b53c4 100644 --- a/src/features/MatchPage/store/hooks/useMatchData.tsx +++ b/src/features/MatchPage/store/hooks/useMatchData.tsx @@ -36,13 +36,25 @@ export const useMatchData = (profile: MatchInfo) => { setFullMatchPlaylistDuration, setSelectedPlaylist, } = useMatchPlaylists(profile) + const { events, fetchMatchEvents } = useEvents() - const { setStatsType, statsType } = useStatsTab() + + const { + isPlayersStatsFetching, + isTeamsStatsFetching, + setIsPlayersStatsFetching, + setIsTeamsStatsFetching, + statsType, + toggleStatsType, + } = useStatsTab() + const { teamsStats } = useTeamsStats({ matchProfile: profile, playingProgress, + setIsTeamsStatsFetching, statsType, }) + const { isEmptyPlayersStats, playersData, @@ -50,6 +62,7 @@ export const useMatchData = (profile: MatchInfo) => { } = usePlayersStats({ matchProfile: profile, playingProgress, + setIsPlayersStatsFetching, statsType, }) @@ -113,14 +126,16 @@ export const useMatchData = (profile: MatchInfo) => { events, handlePlaylistClick, isEmptyPlayersStats, + isPlayersStatsFetching, + isTeamsStatsFetching, matchPlaylists, playersData, playersStats, selectedPlaylist, setFullMatchPlaylistDuration, setPlayingProgress, - setStatsType, statsType, teamsStats, + toggleStatsType, } } diff --git a/src/features/MatchPage/store/hooks/usePlayersStats.tsx b/src/features/MatchPage/store/hooks/usePlayersStats.tsx index 326d4f51..b5b377ad 100644 --- a/src/features/MatchPage/store/hooks/usePlayersStats.tsx +++ b/src/features/MatchPage/store/hooks/usePlayersStats.tsx @@ -1,3 +1,4 @@ +import type { Dispatch, SetStateAction } from 'react' import { useMemo, useEffect, @@ -26,6 +27,7 @@ const STATS_POLL_INTERVAL = 30000 type UsePlayersStatsArgs = { matchProfile: MatchInfo, playingProgress: number, + setIsPlayersStatsFetching: Dispatch>, statsType: StatsType, } @@ -37,6 +39,7 @@ type PlayersData = { export const usePlayersStats = ({ matchProfile, playingProgress, + setIsPlayersStatsFetching, statsType, }: UsePlayersStatsArgs) => { const [playersStats, setPlayersStats] = useObjectState>({}) @@ -58,26 +61,18 @@ export const usePlayersStats = ({ || isEmpty(playersData[matchProfile?.team1.id === teamId ? 'team1' : 'team2']) ) - const fetchPlayers = useMemo(() => throttle((second?: number) => { - if (!matchProfile?.team1.id || !matchProfile?.team1.id) return + const fetchPlayers = useMemo(() => throttle(async (second?: number) => { + if (!matchProfile?.team1.id || !matchProfile?.team2.id) return null try { - getMatchParticipants({ + return getMatchParticipants({ matchId, second, sportType, - }).then((data) => { - const team1Players = find(data, { team_id: matchProfile.team1.id })?.players || [] - const team2Players = find(data, { team_id: matchProfile.team2.id })?.players || [] - - setPlayersData({ - team1: team1Players, - team2: team2Players, - }) }) - - // eslint-disable-next-line no-empty - } catch (e) {} + } catch (e) { + return Promise.reject(e) + } }, REQUEST_DELAY), [ matchId, matchProfile?.team1.id, @@ -85,67 +80,83 @@ export const usePlayersStats = ({ sportType, ]) - const fetchPlayersStats = useMemo(() => throttle((second?: number) => { - if (!sportName || !matchProfile?.team1.id || !matchProfile?.team2.id) return + const fetchPlayersStats = useMemo(() => (async (team: 'team1' | 'team2', second?: number) => { + if (!sportName || !matchProfile?.[team].id) return null try { - getPlayersStats({ + return getPlayersStats({ matchId, second, sportName, - teamId: matchProfile.team1.id, - }).then((data) => setPlayersStats({ [matchProfile.team1.id]: data })) - - getPlayersStats({ - matchId, - second, - sportName, - teamId: matchProfile.team2.id, - }).then((data) => setPlayersStats({ [matchProfile?.team2.id]: data })) - // eslint-disable-next-line no-empty - } catch (e) {} - }, REQUEST_DELAY), [ + teamId: matchProfile[team].id, + }) + } catch (e) { + return Promise.reject(e) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }), [ matchId, + sportName, matchProfile?.team1.id, matchProfile?.team2.id, + ]) + + const fetchData = useMemo(() => throttle(async (second?: number) => { + const [res1, res2, res3] = await Promise.all([ + fetchPlayers(second), + fetchPlayersStats('team1', second), + fetchPlayersStats('team2', second), + ]) + + const team1Players = find(res1, { team_id: matchProfile?.team1.id })?.players || [] + const team2Players = find(res1, { team_id: matchProfile?.team2.id })?.players || [] + + setPlayersData({ + team1: team1Players, + team2: team2Players, + }) + + setPlayersStats({ + ...(matchProfile?.team1.id && res2 && { [matchProfile.team1.id]: res2 }), + ...(matchProfile?.team2.id && res3 && { [matchProfile.team2.id]: res3 }), + }) + + setIsPlayersStatsFetching(false) + }, REQUEST_DELAY), [ + fetchPlayers, + fetchPlayersStats, setPlayersStats, - sportName, + matchProfile?.team1.id, + matchProfile?.team2.id, + setIsPlayersStatsFetching, ]) useEffect(() => { let interval: NodeJS.Timeout - fetchPlayers() - if (!isCurrentStats) { - fetchPlayersStats() + fetchData() } - if (matchProfile?.live) { + if (matchProfile?.live && !isCurrentStats) { interval = setInterval(() => { - if (isCurrentStats) return - - fetchPlayersStats() - fetchPlayers() + fetchData() }, STATS_POLL_INTERVAL) } return () => clearInterval(interval) }, [ - fetchPlayersStats, - fetchPlayers, + fetchData, isCurrentStats, matchProfile?.live, ]) useEffect(() => { if (isCurrentStats) { - fetchPlayersStats(progressSec) - fetchPlayers(progressSec) + fetchData(progressSec) } }, [ - fetchPlayersStats, - fetchPlayers, + fetchData, progressSec, isCurrentStats, matchProfile?.live, diff --git a/src/features/MatchPage/store/hooks/useStatsTab.tsx b/src/features/MatchPage/store/hooks/useStatsTab.tsx index 9a2a18f1..39064d06 100644 --- a/src/features/MatchPage/store/hooks/useStatsTab.tsx +++ b/src/features/MatchPage/store/hooks/useStatsTab.tsx @@ -4,9 +4,25 @@ import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/confi export const useStatsTab = () => { const [statsType, setStatsType] = useState(StatsType.FINAL_STATS) + const [isPlayersStatsFetching, setIsPlayersStatsFetching] = useState(false) + const [isTeamsStatsFetching, setIsTeamsStatsFetching] = useState(false) + + const isFinalStatsType = statsType === StatsType.FINAL_STATS + + const toggleStatsType = () => { + const newStatsType = isFinalStatsType ? StatsType.CURRENT_STATS : StatsType.FINAL_STATS + + setStatsType(newStatsType) + setIsTeamsStatsFetching(true) + setIsPlayersStatsFetching(true) + } return { - setStatsType, + isPlayersStatsFetching, + isTeamsStatsFetching, + setIsPlayersStatsFetching, + setIsTeamsStatsFetching, statsType, + toggleStatsType, } } diff --git a/src/features/MatchPage/store/hooks/useTeamsStats.tsx b/src/features/MatchPage/store/hooks/useTeamsStats.tsx index f9f6ea58..895d48fc 100644 --- a/src/features/MatchPage/store/hooks/useTeamsStats.tsx +++ b/src/features/MatchPage/store/hooks/useTeamsStats.tsx @@ -1,3 +1,4 @@ +import type { Dispatch, SetStateAction } from 'react' import { useEffect, useState, @@ -19,12 +20,14 @@ const STATS_POLL_INTERVAL = 30000 type UseTeamsStatsArgs = { matchProfile: MatchInfo, playingProgress: number, + setIsTeamsStatsFetching: Dispatch>, statsType: StatsType, } export const useTeamsStats = ({ matchProfile, playingProgress, + setIsTeamsStatsFetching, statsType, }: UseTeamsStatsArgs) => { const [teamsStats, setTeamsStats] = useState<{ @@ -37,32 +40,37 @@ export const useTeamsStats = ({ const isCurrentStats = statsType === StatsType.CURRENT_STATS - const fetchTeamsStats = useMemo(() => throttle((second?: number) => { + const fetchTeamsStats = useMemo(() => throttle(async (second?: number) => { if (!sportName) return - getTeamsStats({ - matchId, - second, - sportName, - }).then(setTeamsStats) - }, REQUEST_DELAY), [matchId, sportName]) + try { + const data = await getTeamsStats({ + matchId, + second, + sportName, + }) + + setTeamsStats(data) + setIsTeamsStatsFetching(false) + + // eslint-disable-next-line no-empty + } catch (e) {} + }, REQUEST_DELAY), [matchId, setIsTeamsStatsFetching, sportName]) useEffect(() => { - let timer: ReturnType + let interval: NodeJS.Timeout if (!isCurrentStats) { fetchTeamsStats() } - if (matchProfile?.live) { - timer = setInterval(() => { - if (isCurrentStats) return - + if (matchProfile?.live && !isCurrentStats) { + interval = setInterval(() => { fetchTeamsStats() }, STATS_POLL_INTERVAL) } - return () => clearInterval(timer) + return () => clearInterval(interval) }, [fetchTeamsStats, matchProfile?.live, isCurrentStats]) useEffect(() => { diff --git a/src/features/MatchSidePlaylists/components/CircleAnimationBar/index.tsx b/src/features/MatchSidePlaylists/components/CircleAnimationBar/index.tsx new file mode 100644 index 00000000..56e90dda --- /dev/null +++ b/src/features/MatchSidePlaylists/components/CircleAnimationBar/index.tsx @@ -0,0 +1,16 @@ +import styled from 'styled-components/macro' + +import { CircleAnimationBar as CircleAnimationBarBase } from 'features/CircleAnimationBar' + +export const CircleAnimationBar = styled(CircleAnimationBarBase)` + position: absolute; + transform: translateY(-50%); + + circle { + stroke: #4086C6; + } + + text { + fill: ${({ theme }) => theme.colors.white}; + } +` diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/Cell.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/Cell.tsx new file mode 100644 index 00000000..01733beb --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/Cell.tsx @@ -0,0 +1,65 @@ +import type { PropsWithChildren, HTMLProps } from 'react' +import { memo } from 'react' +import { createPortal } from 'react-dom' + +import { isMobileDevice } from 'config' + +import { useModalRoot, useTooltip } from 'hooks' + +import { Tooltip, CellContainer } from './styled' + +type CellProps = { + anchorId?: string, + as?: 'td' | 'th', + clickable?: boolean, + columnWidth?: number, + sorted?: boolean, + tooltipText?: string, +} & HTMLProps + +const CellFC = ({ + anchorId, + as, + children, + clickable, + columnWidth, + onClick, + sorted, + tooltipText, +}: PropsWithChildren) => { + const { + isTooltipShown, + onMouseLeave, + onMouseOver, + tooltipStyle, + } = useTooltip() + + const modalRoot = useModalRoot() + + return ( + + {children} + {isTooltipShown && modalRoot.current && createPortal( + + {tooltipText} + , + modalRoot.current, + )} + + ) +} + +export const Cell = memo(CellFC) diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/config.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/config.tsx index c0ac6c10..a8dd82cf 100644 --- a/src/features/MatchSidePlaylists/components/PlayersTable/config.tsx +++ b/src/features/MatchSidePlaylists/components/PlayersTable/config.tsx @@ -1,6 +1,7 @@ -export const PARAM_COLUMN_WIDTH = 50 +export const PARAM_COLUMN_WIDTH_DEFAULT = 40 +export const FIRST_COLUMN_WIDTH_DEFAULT = 105 +export const FIRST_COLUMN_WIDTH_EXPANDED = 220 export const REQUEST_DELAY = 3000 export const STATS_POLL_INTERVAL = 30000 -export const DISPLAYED_PARAMS_COLUMNS = 4 -export const FIRST_COLUMN_WIDTH_DEFAULT = 100 +export const DISPLAYED_PARAMS_COLUMNS = 5 export const SCROLLBAR_WIDTH = 8 diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx index ff2b8e3a..0a7f457a 100644 --- a/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx +++ b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx @@ -34,8 +34,8 @@ export const usePlayers = ({ sortCondition, teamId }: UsePlayersArgs) => { const getDisplayedValue = ({ val }: PlayerParam) => (isNil(val) ? '-' : val) - const getFullName = useCallback((player: Player) => ( - trim(`${player[`firstname_${suffix}`]} ${player[`lastname_${suffix}`]}`) + const getPlayerName = useCallback((player: Player) => ( + trim(player[`lastname_${suffix}`] || '') ), [suffix]) const getParamValue = useCallback((playerId: number, paramId: number) => { @@ -49,7 +49,7 @@ export const usePlayers = ({ sortCondition, teamId }: UsePlayersArgs) => { const players = playersData[matchProfile?.team1.id === teamId ? 'team1' : 'team2'] return isNil(sortCondition.paramId) - ? orderBy(players, getFullName) + ? orderBy(players, getPlayerName) : orderBy( players, [ @@ -58,12 +58,12 @@ export const usePlayers = ({ sortCondition, teamId }: UsePlayersArgs) => { return isNil(paramValue) ? -1 : paramValue }, - getFullName, + getPlayerName, ], sortCondition.dir, ) }, [ - getFullName, + getPlayerName, getParamValue, playersData, matchProfile?.team1.id, @@ -74,7 +74,7 @@ export const usePlayers = ({ sortCondition, teamId }: UsePlayersArgs) => { return { getDisplayedValue, - getFullName, + getPlayerName, getPlayerParams, isExpanded, players: sortedPlayers, diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx index 5bc2f55c..d63924d2 100644 --- a/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx +++ b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx @@ -4,9 +4,11 @@ import type { SetStateAction, } from 'react' import { + useCallback, useRef, useState, useEffect, + useLayoutEffect, useMemo, } from 'react' @@ -29,9 +31,9 @@ import { useLexicsConfig } from 'features/LexicsStore' import type { SortCondition } from '../types' import { - PARAM_COLUMN_WIDTH, - DISPLAYED_PARAMS_COLUMNS, + PARAM_COLUMN_WIDTH_DEFAULT, FIRST_COLUMN_WIDTH_DEFAULT, + DISPLAYED_PARAMS_COLUMNS, SCROLLBAR_WIDTH, } from '../config' @@ -51,8 +53,13 @@ export const useTable = ({ const [showLeftArrow, setShowLeftArrow] = useState(false) const [showRightArrow, setShowRightArrow] = useState(false) + const [paramColumnWidth, setParamColumnWidth] = useState(PARAM_COLUMN_WIDTH_DEFAULT) - const { isOpen: isExpanded, toggle: toggleIsExpanded } = useToggle() + const { + close: reduceTable, + isOpen: isExpanded, + toggle: toggleIsExpanded, + } = useToggle() const { playersStats } = useMatchPageStore() const params = useMemo(() => ( @@ -95,27 +102,38 @@ export const useTable = ({ const paramsCount = size(params) - const getParamColumnWidth = () => { - const rest = ( - (containerRef.current?.clientWidth || 0) - FIRST_COLUMN_WIDTH_DEFAULT - SCROLLBAR_WIDTH + const getParamColumnWidth = useCallback(() => { + const paramsTableWidth = ( + (containerRef.current?.clientWidth || 0) + - FIRST_COLUMN_WIDTH_DEFAULT + - SCROLLBAR_WIDTH - 8 ) - const desktopWith = PARAM_COLUMN_WIDTH - const mobileWidth = paramsCount < DISPLAYED_PARAMS_COLUMNS ? 0 : rest / DISPLAYED_PARAMS_COLUMNS - - return isMobileDevice ? mobileWidth : desktopWith - } + return isExpanded + ? PARAM_COLUMN_WIDTH_DEFAULT + : paramsTableWidth / DISPLAYED_PARAMS_COLUMNS + }, [isExpanded]) - const getFirstColumnWidth = () => { - if (isExpanded) return 0 + const slideLeft = () => { + const { + clientHeight = 0, + clientWidth = 0, + scrollHeight = 0, + scrollLeft = 0, + scrollWidth = 0, + } = tableWrapperRef.current || {} - return paramsCount < DISPLAYED_PARAMS_COLUMNS ? 0 : FIRST_COLUMN_WIDTH_DEFAULT - } + const hasVerticalScroll = scrollHeight > clientHeight + const scrollRight = scrollWidth - (scrollLeft + clientWidth) - const paramColumnWidth = getParamColumnWidth() - const firstColumnWidth = getFirstColumnWidth() + const scrollBy = scrollRight === 0 + ? paramColumnWidth - (hasVerticalScroll ? SCROLLBAR_WIDTH : SCROLLBAR_WIDTH * 2) + : paramColumnWidth - const slideLeft = () => tableWrapperRef.current?.scrollBy(-paramColumnWidth, 0) - const slideRight = () => tableWrapperRef.current?.scrollBy(paramColumnWidth, 0) + tableWrapperRef.current?.scrollBy(-scrollBy, 0) + } + const slideRight = () => { + tableWrapperRef.current?.scrollBy(paramColumnWidth, 0) + } const getDisplayedValue = ({ val }: PlayerParam) => (isNil(val) ? '-' : round(val, 2)) @@ -133,13 +151,21 @@ export const useTable = ({ } const handleSortClick = (paramId: number) => () => { - setSortCondition((curr) => ({ - dir: curr.dir === 'asc' || curr.paramId !== paramId ? 'desc' : 'asc', - paramId, - })) + setSortCondition((curr) => { + const clicksCount = curr.paramId === paramId || isNil(curr.paramId) + ? curr.clicksCount + 1 + : 1 + + // При третьем клике сбрасываем счетчик клика и убираем сортировку по параметру + return { + clicksCount: clicksCount === 3 ? 0 : clicksCount, + dir: curr.dir === 'asc' || curr.paramId !== paramId ? 'desc' : 'asc', + paramId: clicksCount === 3 ? null : paramId, + } + }) } - useEffect(() => { + useLayoutEffect(() => { const { clientWidth = 0, scrollLeft = 0, @@ -149,11 +175,20 @@ export const useTable = ({ const scrollRight = scrollWidth - (scrollLeft + clientWidth) setShowRightArrow(scrollRight > 0) - }, [isExpanded]) + }, [isExpanded, tableWrapperRef.current?.clientWidth, paramsCount]) + + useLayoutEffect(() => { + setParamColumnWidth(getParamColumnWidth()) + }, [getParamColumnWidth, tableWrapperRef.current?.clientWidth]) + + useEffect(() => { + if (isExpanded && paramsCount <= DISPLAYED_PARAMS_COLUMNS) { + reduceTable() + } + }, [isExpanded, paramsCount, reduceTable]) return { containerRef, - firstColumnWidth, getDisplayedValue, handleScroll, handleSortClick, diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx index 9e5ce602..e0de514f 100644 --- a/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx +++ b/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx @@ -1,38 +1,41 @@ import { Fragment } from 'react' import map from 'lodash/map' -import includes from 'lodash/includes' +// import includes from 'lodash/includes' import { PlayerParam } from 'requests' -import { T9n } from 'features/T9n' +import { usePageParams } from 'hooks' + +import { useLexicsStore } from 'features/LexicsStore' +import { useMatchPageStore } from 'features/MatchPage/store' +import { Loader } from 'features/Loader' +import { defaultTheme } from 'features/Theme/config' import type { PlayersTableProps } from './types' +import { FIRST_COLUMN_WIDTH_DEFAULT, FIRST_COLUMN_WIDTH_EXPANDED } from './config' import { usePlayersTable } from './hooks' +import { Cell } from './Cell' import { Container, TableWrapper, Table, - FirstColumn, - Cell, + Header, Row, PlayerNum, - PlayerNameWrapper, PlayerName, ParamShortTitle, ArrowButtonRight, ArrowButtonLeft, Arrow, ExpandButton, - Tooltip, } from './styled' export const PlayersTable = (props: PlayersTableProps) => { const { containerRef, - firstColumnWidth, getDisplayedValue, - getFullName, + getPlayerName, getPlayerParams, handleScroll, handleSortClick, @@ -49,6 +52,17 @@ export const PlayersTable = (props: PlayersTableProps) => { tableWrapperRef, toggleIsExpanded, } = usePlayersTable(props) + const { translate } = useLexicsStore() + const { sportName } = usePageParams() + const { isPlayersStatsFetching } = useMatchPageStore() + + if (isPlayersStatsFetching) { + return ( + + ) + } + + const firstColumnWidth = isExpanded ? FIRST_COLUMN_WIDTH_EXPANDED : FIRST_COLUMN_WIDTH_DEFAULT return ( { > {!isExpanded && ( - {showLeftArrow && ( - - - - )} {showRightArrow && ( { )} )} - - - - {showExpandButton && ( - - - - - )} - - - {map(players, (player) => { - const fullName = getFullName(player) - - return ( - - - - {player.club_shirt_num} - {' '} - - - {fullName} - - - {fullName} - - - - - ) - })} - - - - {map(params, ({ - id, - lexic, - lexica_short, - }) => ( +
+
+ - - - - + {showLeftArrow && ( + + + + )} + {showExpandButton && ( + + + + + )} - ))} - + {map(params, ({ + id, + lexic, + lexica_short, + }) => ( + + + + ))} + +
- {map(players, (player) => ( - - {map(params, ({ id }) => { - const playerParam = getPlayerParams(player.id)[id] as PlayerParam | undefined - const value = playerParam ? getDisplayedValue(playerParam) : '-' - const clickable = Boolean(playerParam?.clickable) && !includes([0, '-'], value) - const sorted = sortCondition.paramId === id + + {map(players, (player) => { + const playerName = getPlayerName(player) + const playerNum = player.num ?? player.club_shirt_num + const playerProfileUrl = `/${sportName}/players/${player.id}` - return ( - - {value} + return ( + + + {playerNum}{' '} + + {playerName} + - ) - })} - - ))} + {map(params, ({ id }) => { + const playerParam = getPlayerParams(player.id)[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 === id + + return ( + + {value} + + ) + })} + + ) + })} +
diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx index a0d9f33d..851c2690 100644 --- a/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx +++ b/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx @@ -1,3 +1,5 @@ +import { Link } from 'react-router-dom' + import styled, { css } from 'styled-components/macro' import { isMobileDevice } from 'config' @@ -15,8 +17,12 @@ type ContainerProps = { } export const Container = styled.div` + --bgColor: #333; + ${({ isExpanded }) => (isExpanded - ? '' + ? css` + --bgColor: rgba(51, 51, 51, 0.7); + ` : css` position: relative; `)} @@ -27,15 +33,21 @@ type TableWrapperProps = { } export const TableWrapper = styled.div` - display: flex; max-width: 100%; - max-height: calc(100vh - 235px); + max-height: calc(100vh - 203px); border-radius: 5px; overflow-x: auto; scroll-behavior: smooth; - background-color: #333333; + 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` @@ -45,194 +57,217 @@ export const TableWrapper = styled.div` : '')} ` -export const Table = styled.div` - flex-grow: 1; +export const Table = styled.table` border-radius: 5px; + border-spacing: 0; border-collapse: collapse; letter-spacing: -0.078px; + table-layout: fixed; ` export const Tooltip = styled(TooltipWrapper)` - left: auto; + display: block; padding: 2px 10px; border-radius: 6px; transform: none; font-size: 11px; line-height: 1; color: ${({ theme }) => theme.colors.black}; + z-index: 999; ::before { display: none; } ` -export const ParamShortTitle = styled(T9n)` +type ParamShortTitleProps = { + showLeftArrow?: boolean, + sortDirection: 'asc' | 'desc', + sorted?: boolean, +} + +export const ParamShortTitle = styled(T9n)` + position: relative; text-transform: uppercase; -` -export const Row = styled.div` - display: flex; - width: 100%; - height: 45px; - border-bottom: 0.5px solid rgba(255, 255, 255, 0.5); + ::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; + `)} - :first-child { - position: sticky; - left: 0; - top: 0; - z-index: 1; + ${({ showLeftArrow }) => (showLeftArrow + ? '' + : css` + z-index: 1; + `)} } ` -type TdProps = { +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, - headerCell?: boolean, sorted?: boolean, } -export const Cell = styled.div.attrs(({ clickable }: TdProps) => ({ +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; - color: ${({ - clickable, - headerCell, - theme, - }) => (clickable && !headerCell ? '#5EB2FF' : theme.colors.white)}; + font-weight: ${({ sorted }) => (sorted ? 'bold' : 'normal')}; + color: ${({ clickable, theme }) => (clickable ? '#5EB2FF' : theme.colors.white)}; white-space: nowrap; - background-color: #333333; + background-color: var(--bgColor); - ${Tooltip} { - top: 35px; - } - - :hover { - ${Tooltip} { - display: block; - } + :first-child { + position: sticky; + left: 0; + justify-content: unset; + padding-left: 10px; + text-align: left; + cursor: unset; + z-index: 1; } - ${({ headerCell }) => (headerCell - ? '' - : css` - :first-child { - justify-content: unset; - padding-left: 13px; - color: ${({ theme }) => theme.colors.white}; - } - `)} - - ${({ sorted }) => (sorted + ${({ clickable }) => (clickable ? css` - font-weight: bold; + cursor: pointer; ` : '')} - ${({ clickable, headerCell }) => (clickable || headerCell + ${({ as, sorted }) => (as === 'th' ? css` - cursor: pointer; + font-weight: ${sorted ? '700' : '600'}; + font-size: ${sorted ? 13 : 11}px; ` : '')} ` -type FirstColumnProps = { - columnWidth?: number, -} - -export const FirstColumn = styled.div` +export const Header = styled.thead` position: sticky; left: 0; - width: ${({ columnWidth }) => (columnWidth ? `${columnWidth}px` : 'auto')}; -` - -export const PlayerNum = styled.span` - display: inline-block; - width: 20px; - flex-shrink: 0; - text-align: center; - color: rgba(255, 255, 255, 0.5); -` - -type PlayerNameProps = { - columnWidth?: number, -} + top: 0; + z-index: 2; -export const PlayerName = styled.span` - display: inline-block; - margin-top: 2px; - text-overflow: ellipsis; - overflow: hidden; + ${CellContainer} { + background-color: #292929; + color: ${({ theme }) => theme.colors.white}; + cursor: pointer; + } - ${({ columnWidth }) => (columnWidth - ? css` - max-width: calc(${columnWidth}px - 31px); - ` - : css` - max-width: 110px; - `)} + ${CellContainer}:first-child { + cursor: unset; + } ` -export const PlayerNameWrapper = styled.span` +export const Row = styled.tr` position: relative; + display: flex; + width: 100%; + height: 45px; + border-bottom: 0.5px solid ${({ theme }) => theme.colors.secondary}; + z-index: 1; - ${Tooltip} { - top: 15px; + :last-child:not(:first-child) { + border: none; } :hover { - ${Tooltip} { - display: block; + ${CellContainer}:not(th) { + background-color: #484848; + } + + ${PlayerName} { + text-decoration: underline; + font-weight: 600; } } ` +export const Arrow = styled(ArrowBase)` + width: 10px; + height: 10px; + + ${isMobileDevice + ? css` + border-color: ${({ theme }) => theme.colors.white}; + ` + : ''}; +` + const ArrowButton = styled(ArrowButtonBase)` position: absolute; - width: 17px; - margin-top: 2px; - background-color: #333333; + width: 20px; + height: 44px; + margin-top: 0; z-index: 3; + background-color: #292929; ${isMobileDevice ? css` - height: 45px; - margin-top: 0; + 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)` - left: 75px; + right: -5px; ` -export const Arrow = styled(ArrowBase)` - width: 10px; - height: 10px; - - ${isMobileDevice - ? css` - border-color: ${({ theme }) => theme.colors.white}; - ` - : ''}; -` +type ExpandButtonProps = { + isExpanded?: boolean, +} -export const ExpandButton = styled(ArrowButton)` +export const ExpandButton = styled(ArrowButton)` left: 20px; top: 0; ${Arrow} { - left: 0; + left: ${({ isExpanded }) => (isExpanded ? -6 : -2)}px; :last-child { margin-left: 7px; diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx index 610d2c8c..f3a3afb2 100644 --- a/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx @@ -4,11 +4,18 @@ 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 { Props } from './types' import { useTeamsStatsTable } from './hooks' import { Container, + TableWrapper, + Table, + Header, Row, + Cell, TeamShortName, ParamValueContainer, ParamValue, @@ -16,78 +23,113 @@ import { Divider, } from './styled' -export const TeamsStatsTable = () => { - const { profile, teamsStats } = useMatchPageStore() +export const TeamsStatsTable = (props: Props) => { + const { + isTeamsStatsFetching, + profile, + teamsStats, + } = useMatchPageStore() + const { getDisplayedValue, getStatItemById, - isClickable, + // isClickable, } = useTeamsStatsTable() - const { lang } = useLexicsStore() + + 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_${lang === 'ru' ? 'ru' : 'en'}`] + + {map(teamsStats[profile.team1.id], (team1StatItem) => { + const team2StatItem = getStatItemById(team1StatItem.param1.id) + const statItemTitle = team1StatItem[`name_${shortSuffix}`] - return ( - - - - {getDisplayedValue(team1StatItem.param1.val)} - - {team1StatItem.param2 && ( - - / - - {getDisplayedValue(team1StatItem.param2.val)} - - - )} - + return ( + + + + + {getDisplayedValue(team1StatItem.param1.val)} + + {team1StatItem.param2 && ( + + / + + {getDisplayedValue(team1StatItem.param2.val)} + + + )} + + - {statItemTitle} + + {statItemTitle} + - {team2StatItem && ( - - - {getDisplayedValue(team2StatItem.param1.val)} - - {team2StatItem.param2 && ( - - / - - {getDisplayedValue(team2StatItem.param2.val)} - - - )} - - )} - - ) - })} + + {team2StatItem && ( + + + {getDisplayedValue(team2StatItem.param1.val)} + + {team2StatItem.param2 && ( + + / + + {getDisplayedValue(team2StatItem.param2.val)} + + + )} + + )} + + + ) + })} + +
+
) } diff --git a/src/features/MatchSidePlaylists/index.tsx b/src/features/MatchSidePlaylists/index.tsx index e50cafe2..a7baaa77 100644 --- a/src/features/MatchSidePlaylists/index.tsx +++ b/src/features/MatchSidePlaylists/index.tsx @@ -60,7 +60,7 @@ export const MatchSidePlaylists = ({ hasLessThanFourTabs, isEventTabVisible, isPlayersTabVisible, - // isStatsTabVisible, + isStatsTabVisible, isWatchTabVisible, onTabClick, playListFilter, @@ -134,7 +134,7 @@ export const MatchSidePlaylists = ({ ) : null} - {/* {isStatsTabVisible ? ( + {isStatsTabVisible ? ( onTabClick(Tabs.STATS)} @@ -142,7 +142,7 @@ export const MatchSidePlaylists = ({ - ) : null} */} + ) : null} diff --git a/src/features/MatchSidePlaylists/styled.tsx b/src/features/MatchSidePlaylists/styled.tsx index 0ba4d49a..fb49d090 100644 --- a/src/features/MatchSidePlaylists/styled.tsx +++ b/src/features/MatchSidePlaylists/styled.tsx @@ -13,21 +13,14 @@ export const Wrapper = styled.div` ? css` overflow-y: auto; width: 100%; + padding-right: 0; ${customScrollbar} ` : ''}; ` -export const TabsWrapper = styled.div` - padding: 0 30px; - - ${isMobileDevice - ? css` - padding: 0 5px; - ` - : ''}; -` +export const TabsWrapper = styled.div`` type TabsGroupProps = { hasLessThanFourTabs?: boolean, @@ -35,18 +28,22 @@ type TabsGroupProps = { export const TabsGroup = styled.div.attrs({ role: 'tablist' })` display: flex; - height: 45px; - padding-top: 10px; + justify-content: center; + gap: 20px; ${({ hasLessThanFourTabs }) => (hasLessThanFourTabs ? css` - height: 40px; - + padding-top: 10px; + ${Tab} { justify-content: center; flex-direction: row; gap: 5px; } + + ${TabIcon} { + margin-bottom: 0; + } ` : '')} @@ -68,7 +65,8 @@ export const Tab = styled.button.attrs({ role: 'tab' })` flex-direction: column; justify-content: space-between; align-items: center; - flex: 1; + padding-left: 0; + padding-right: 0; opacity: 0.4; cursor: pointer; border: none; @@ -90,6 +88,8 @@ type TabIconProps = { 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; @@ -97,7 +97,7 @@ export const TabIcon = styled.div` ${({ icon }) => (icon === 'players' ? css` - background-size: 23px; + background-size: 25px; ` : '')} ` @@ -125,6 +125,7 @@ export const Container = styled.div` ${isMobileDevice ? css` padding: 0 5px; + padding-bottom: 20px; overflow-y: hidden; max-height: initial; 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/useTooltip.tsx b/src/hooks/useTooltip.tsx new file mode 100644 index 00000000..161f12d2 --- /dev/null +++ b/src/hooks/useTooltip.tsx @@ -0,0 +1,88 @@ +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`, + + ...(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/getTeamsStats.tsx b/src/requests/getTeamsStats.tsx index acdf40bd..18fdcde9 100644 --- a/src/requests/getTeamsStats.tsx +++ b/src/requests/getTeamsStats.tsx @@ -2,6 +2,8 @@ import isUndefined from 'lodash/isUndefined' import { callApi } from 'helpers' +import { STATS_API_URL } from 'config' + export type Param = { clickable: boolean, data_type: string, @@ -47,7 +49,7 @@ export const getTeamsStats = async ({ const response: Response = await callApi({ config, - url: `http://136.243.17.103:8888/${sportName}/matches/${matchId}/teams/stats?group_num=0${isUndefined(second) ? '' : `&second=${second}`}`, + url: `${STATS_API_URL}/${sportName}/matches/${matchId}/teams/stats?group_num=0${isUndefined(second) ? '' : `&second=${second}`}`, }) if (response.error) Promise.reject(response)