feat(in-142): match stats tab

pull/101/head
Ruslan Khayrullin 3 years ago
parent 59766a9601
commit 459c39a0ea
  1. 3
      public/images/matchTabs/bets.svg
  2. 3
      public/images/matchTabs/chat.svg
  3. 6
      public/images/matchTabs/players.svg
  4. 4
      public/images/matchTabs/plays.svg
  5. 12
      public/images/matchTabs/stats.svg
  6. 3
      public/images/matchTabs/watch.svg
  7. 3
      public/images/sortUp.svg
  8. 1
      src/config/index.tsx
  9. 3
      src/config/keyboardKeys.tsx
  10. 5
      src/config/lexics/indexLexics.tsx
  11. 7
      src/config/routes.tsx
  12. 43
      src/features/CircleAnimationBar/index.tsx
  13. 21
      src/features/CircleAnimationBar/styled.tsx
  14. 4
      src/features/LexicsStore/helpers/index.tsx
  15. 2
      src/features/LexicsStore/hooks/index.tsx
  16. 22
      src/features/MatchCard/CardFrontside/index.tsx
  17. 26
      src/features/MatchCard/styled.tsx
  18. 1
      src/features/MatchPage/components/FinishedMatch/helpers.tsx
  19. 14
      src/features/MatchPage/components/FinishedMatch/hooks/index.tsx
  20. 12
      src/features/MatchPage/components/FinishedMatch/index.tsx
  21. 65
      src/features/MatchPage/components/LiveMatch/helpers.tsx
  22. 6
      src/features/MatchPage/components/LiveMatch/hooks/index.tsx
  23. 11
      src/features/MatchPage/components/LiveMatch/hooks/useChapters.tsx
  24. 10
      src/features/MatchPage/components/LiveMatch/index.tsx
  25. 2
      src/features/MatchPage/components/MatchDescription/index.tsx
  26. 11
      src/features/MatchPage/helpers/fullMatchDuration.tsx
  27. 53
      src/features/MatchPage/helpers/getHalfTime.tsx
  28. 148
      src/features/MatchPage/store/hooks/index.tsx
  29. 16
      src/features/MatchPage/store/hooks/useMatchData.tsx
  30. 4
      src/features/MatchPage/store/hooks/useMatchPlaylists.tsx
  31. 201
      src/features/MatchPage/store/hooks/usePlayersStats.tsx
  32. 13
      src/features/MatchPage/store/hooks/useSelectedPlaylist.tsx
  33. 144
      src/features/MatchPage/store/hooks/useStatsTab.tsx
  34. 113
      src/features/MatchPage/store/hooks/useTeamsStats.tsx
  35. 14
      src/features/MatchPage/store/hooks/useTournamentData.tsx
  36. 18
      src/features/MatchSidePlaylists/components/CircleAnimationBar/index.tsx
  37. 41
      src/features/MatchSidePlaylists/components/EventButton/index.tsx
  38. 46
      src/features/MatchSidePlaylists/components/MatchPlaylists/index.tsx
  39. 51
      src/features/MatchSidePlaylists/components/Matches/index.tsx
  40. 40
      src/features/MatchSidePlaylists/components/Matches/styled.tsx
  41. 5
      src/features/MatchSidePlaylists/components/PlayButton/index.tsx
  42. 9
      src/features/MatchSidePlaylists/components/PlayersPlaylists/index.tsx
  43. 33
      src/features/MatchSidePlaylists/components/PlayersPlaylists/styled.tsx
  44. 87
      src/features/MatchSidePlaylists/components/PlayersTable/Cell.tsx
  45. 7
      src/features/MatchSidePlaylists/components/PlayersTable/config.tsx
  46. 73
      src/features/MatchSidePlaylists/components/PlayersTable/hooks/index.tsx
  47. 79
      src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx
  48. 262
      src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx
  49. 201
      src/features/MatchSidePlaylists/components/PlayersTable/index.tsx
  50. 283
      src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx
  51. 9
      src/features/MatchSidePlaylists/components/PlayersTable/types.tsx
  52. 25
      src/features/MatchSidePlaylists/components/TabEvents/index.tsx
  53. 10
      src/features/MatchSidePlaylists/components/TabEvents/styled.tsx
  54. 25
      src/features/MatchSidePlaylists/components/TabPlayers/index.tsx
  55. 10
      src/features/MatchSidePlaylists/components/TabStats/config.tsx
  56. 77
      src/features/MatchSidePlaylists/components/TabStats/hooks.tsx
  57. 165
      src/features/MatchSidePlaylists/components/TabStats/index.tsx
  58. 134
      src/features/MatchSidePlaylists/components/TabStats/styled.tsx
  59. 92
      src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx
  60. 52
      src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx
  61. 22
      src/features/MatchSidePlaylists/components/TabVideo/styled.tsx
  62. 78
      src/features/MatchSidePlaylists/components/TabWatch/index.tsx
  63. 176
      src/features/MatchSidePlaylists/components/TeamsStatsTable/Cell.tsx
  64. 31
      src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx
  65. 90
      src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx
  66. 126
      src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx
  67. 3
      src/features/MatchSidePlaylists/config.tsx
  68. 73
      src/features/MatchSidePlaylists/hooks.tsx
  69. 57
      src/features/MatchSidePlaylists/index.tsx
  70. 100
      src/features/MatchSidePlaylists/styled.tsx
  71. 56
      src/features/MultiSourcePlayer/hooks/index.tsx
  72. 1
      src/features/MultiSourcePlayer/types.tsx
  73. 11
      src/features/Name/index.tsx
  74. 35
      src/features/StreamPlayer/hooks/index.tsx
  75. 3
      src/features/T9n/index.tsx
  76. 7
      src/features/Tooltip/index.tsx
  77. 25
      src/helpers/getTeamAbbr/index.tsx
  78. 1
      src/helpers/index.tsx
  79. 2
      src/hooks/index.tsx
  80. 5
      src/hooks/useModalRoot.tsx
  81. 1
      src/hooks/usePageParams.tsx
  82. 89
      src/hooks/useTooltip.tsx
  83. 1
      src/requests/getMatchEvents.tsx
  84. 12
      src/requests/getMatchInfo.tsx
  85. 69
      src/requests/getMatchParticipants.tsx
  86. 11
      src/requests/getMatchScore.tsx
  87. 58
      src/requests/getPlayersStats.tsx
  88. 57
      src/requests/getStatsEvents.tsx
  89. 60
      src/requests/getTeamsStats.tsx
  90. 4
      src/requests/index.tsx

@ -0,0 +1,3 @@
<svg width="22" height="16" viewBox="0 0 22 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20.75 6.5C20.9489 6.5 21.1397 6.42098 21.2803 6.28033C21.421 6.13968 21.5 5.94891 21.5 5.75V2C21.5 1.60218 21.342 1.22064 21.0607 0.93934C20.7794 0.658035 20.3978 0.5 20 0.5H2C1.60218 0.5 1.22064 0.658035 0.93934 0.93934C0.658035 1.22064 0.5 1.60218 0.5 2V5.75C0.5 5.94891 0.579018 6.13968 0.71967 6.28033C0.860322 6.42098 1.05109 6.5 1.25 6.5C1.64782 6.5 2.02936 6.65804 2.31066 6.93934C2.59196 7.22064 2.75 7.60218 2.75 8C2.75 8.39782 2.59196 8.77936 2.31066 9.06066C2.02936 9.34196 1.64782 9.5 1.25 9.5C1.05109 9.5 0.860322 9.57902 0.71967 9.71967C0.579018 9.86032 0.5 10.0511 0.5 10.25V14C0.5 14.3978 0.658035 14.7794 0.93934 15.0607C1.22064 15.342 1.60218 15.5 2 15.5H20C20.3978 15.5 20.7794 15.342 21.0607 15.0607C21.342 14.7794 21.5 14.3978 21.5 14V10.25C21.5 10.0511 21.421 9.86032 21.2803 9.71967C21.1397 9.57902 20.9489 9.5 20.75 9.5C20.3522 9.5 19.9706 9.34196 19.6893 9.06066C19.408 8.77936 19.25 8.39782 19.25 8C19.25 7.60218 19.408 7.22064 19.6893 6.93934C19.9706 6.65804 20.3522 6.5 20.75 6.5ZM20 10.9025V14H14.75V11.75H13.25V14H2V10.9025C2.642 10.7347 3.21025 10.3588 3.61582 9.8336C4.02139 9.3084 4.24139 8.66356 4.24139 8C4.24139 7.33644 4.02139 6.6916 3.61582 6.1664C3.21025 5.64121 2.642 5.2653 2 5.0975V2H13.25V4.25H14.75V2H20V5.0975C19.358 5.2653 18.7898 5.64121 18.3842 6.1664C17.9786 6.6916 17.7586 7.33644 17.7586 8C17.7586 8.66356 17.9786 9.3084 18.3842 9.8336C18.7898 10.3588 19.358 10.7347 20 10.9025Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M18 0H2C0.9 0 0 0.9 0 2V20L4 16H18C19.1 16 20 15.1 20 14V2C20 0.9 19.1 0 18 0ZM18 14H4L2 16V2H18V14Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 229 B

@ -0,0 +1,6 @@
<svg width="21" height="21" viewBox="0 0 21 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.1862 14.29C15.1862 14.29 14.4125 13.347 13.7596 13.1778C11.7528 12.67 10.8824 12.0414 10.5681 11.6787C10.689 11.582 10.8099 11.4611 10.9308 11.3402C12.2606 9.84117 12.0672 7.32661 12.043 7.0123C11.8979 3.99001 9.6735 3.26466 8.36787 3.31301C7.06224 3.26466 4.83783 4.01418 4.69276 7.0123C4.66859 7.30244 4.47516 9.81698 5.80497 11.3402C5.92586 11.4853 6.07093 11.6304 6.216 11.7271C6.16764 11.7512 6.14346 11.7754 6.14346 11.7996C5.78079 12.1865 4.93455 12.7184 3.07282 13.2019C2.37164 13.3712 1.59794 14.5076 1.59794 14.5076C1.59794 14.5076 0.928283 15.9908 1.10911 16.1968C2.86924 18.199 5.44868 19.4641 8.31951 19.4641C11.3689 19.4641 14.0895 18.0368 15.8487 15.8148C15.6145 14.8567 15.1862 14.29 15.1862 14.29ZM3.41131 14.5801C2.90357 14.701 2.7585 15.3296 2.7585 15.8374C4.2092 17.1914 6.16764 18.0376 8.31951 18.0376C10.5681 18.0376 12.5991 17.1188 14.0981 15.6681C14.0503 14.7595 13.6004 14.6312 13.4268 14.5817L13.4211 14.5801C12.3331 14.3142 11.5111 13.9998 10.9066 13.6855C10.3021 14.4834 9.35918 14.967 8.34369 14.967C7.35238 14.967 6.4336 14.5076 5.82914 13.7339C5.22469 14.024 4.4268 14.3142 3.41131 14.5801ZM8.36787 10.9775C9.02068 10.9775 9.50425 10.7841 9.86692 10.3731C10.6648 9.47849 10.6648 7.73765 10.5923 7.13319V7.08483C10.5439 6.06934 10.1812 5.36817 9.52843 5.0055C9.02505 4.73093 8.50002 4.73784 8.38365 4.73938C8.37705 4.73946 8.37176 4.73953 8.36787 4.73953H8.27115C8.10191 4.73953 7.61834 4.73953 7.15895 5.0055C6.50614 5.36817 6.14346 6.06934 6.09511 7.08483V7.13319C6.02257 7.73765 6.04675 9.47849 6.84463 10.3731C7.20731 10.7599 7.69087 10.9775 8.34369 10.9775H8.36787ZM8.29533 12.4282C8.02937 12.4282 7.76341 12.4041 7.5458 12.3557C7.42491 12.525 7.25567 12.7426 7.03806 12.936C7.35238 13.3228 7.83594 13.5404 8.34369 13.5404C8.89979 13.5404 9.38336 13.2745 9.72185 12.8635C9.52843 12.67 9.38336 12.5008 9.28664 12.3315C9.02068 12.3799 8.75472 12.4282 8.4404 12.4282H8.29533Z" fill="white"/>
<rect x="13.3672" y="4.10608" width="6.14323" height="1.53581" rx="0.767904" fill="white"/>
<rect x="13.3672" y="6.40979" width="6.14323" height="1.53581" rx="0.767904" fill="white"/>
<rect x="13.3672" y="8.7135" width="6.14323" height="1.53581" rx="0.767904" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

@ -0,0 +1,4 @@
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8.9915 0.666626C4.3915 0.666626 0.666504 4.39996 0.666504 8.99996C0.666504 13.6 4.3915 17.3333 8.9915 17.3333C13.5998 17.3333 17.3332 13.6 17.3332 8.99996C17.3332 4.39996 13.5998 0.666626 8.9915 0.666626ZM8.99984 15.6666C5.3165 15.6666 2.33317 12.6833 2.33317 8.99996C2.33317 5.31663 5.3165 2.33329 8.99984 2.33329C12.6832 2.33329 15.6665 5.31663 15.6665 8.99996C15.6665 12.6833 12.6832 15.6666 8.99984 15.6666Z" fill="white"/>
<path d="M9.4165 5.45829C9.4165 5.11311 9.13668 4.83329 8.7915 4.83329C8.44633 4.83329 8.1665 5.11311 8.1665 5.45829V9.2671C8.1665 9.61837 8.3508 9.94387 8.65201 10.1246L12.0317 12.1524C12.3139 12.3217 12.6797 12.2316 12.851 11.9507C13.0246 11.666 12.9321 11.2942 12.6453 11.1241L9.90623 9.49886C9.60263 9.31873 9.4165 8.99187 9.4165 8.63885V5.45829Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 908 B

@ -0,0 +1,12 @@
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.1 0H0.9C0.4 0 0 0.4 0 0.9V17.1C0 17.5 0.4 18 0.9 18H21.1C21.5 18 22 17.5 22 17.1V0.9C22 0.4 21.5 0 21.1 0ZM20 16H2V2H20V16Z" fill="white"/>
<path d="M13.5 4H9C8.44772 4 8 4.44772 8 5C8 5.55228 8.44772 6 9 6H13.5C14.0523 6 14.5 5.55228 14.5 5C14.5 4.44772 14.0523 4 13.5 4Z" fill="white"/>
<path d="M13.5 8H9C8.44772 8 8 8.44772 8 9C8 9.55228 8.44772 10 9 10H13.5C14.0523 10 14.5 9.55228 14.5 9C14.5 8.44772 14.0523 8 13.5 8Z" fill="white"/>
<path d="M13.5 12H9C8.44772 12 8 12.4477 8 13C8 13.5523 8.44772 14 9 14H13.5C14.0523 14 14.5 13.5523 14.5 13C14.5 12.4477 14.0523 12 13.5 12Z" fill="white"/>
<path d="M4 13C4 13.5523 4.44772 14 5 14C5.55228 14 6 13.5523 6 13C6 12.4477 5.55228 12 5 12C4.44772 12 4 12.4477 4 13Z" fill="white"/>
<path d="M4 9C4 9.55228 4.44772 10 5 10C5.55228 10 6 9.55228 6 9C6 8.44772 5.55228 8 5 8C4.44772 8 4 8.44772 4 9Z" fill="white"/>
<path d="M4 5C4 5.55228 4.44772 6 5 6C5.55228 6 6 5.55228 6 5C6 4.44772 5.55228 4 5 4C4.44772 4 4 4.44772 4 5Z" fill="white"/>
<path d="M16.5 13C16.5 13.5523 16.9477 14 17.5 14C18.0523 14 18.5 13.5523 18.5 13C18.5 12.4477 18.0523 12 17.5 12C16.9477 12 16.5 12.4477 16.5 13Z" fill="white"/>
<path d="M16.5 9C16.5 9.55228 16.9477 10 17.5 10C18.0523 10 18.5 9.55228 18.5 9C18.5 8.44772 18.0523 8 17.5 8C16.9477 8 16.5 8.44772 16.5 9Z" fill="white"/>
<path d="M16.5 5C16.5 5.55228 16.9477 6 17.5 6C18.0523 6 18.5 5.55228 18.5 5C18.5 4.44772 18.0523 4 17.5 4C16.9477 4 16.5 4.44772 16.5 5Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,3 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M8 12.5C8 13.324 8.94076 13.7944 9.6 13.3L12.9333 10.8C13.4667 10.4 13.4667 9.6 12.9333 9.2L9.6 6.7C8.94076 6.20557 8 6.67595 8 7.5V12.5ZM10 0C4.48 0 0 4.48 0 10C0 15.52 4.48 20 10 20C15.52 20 20 15.52 20 10C20 4.48 15.52 0 10 0ZM10 18C5.59 18 2 14.41 2 10C2 5.59 5.59 2 10 2C14.41 2 18 5.59 18 10C18 14.41 14.41 18 10 18Z" fill="white"/>
</svg>

After

Width:  |  Height:  |  Size: 451 B

@ -0,0 +1,3 @@
<svg width="7" height="6" viewBox="0 0 7 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.06699 0.75C3.25944 0.416667 3.74056 0.416667 3.93301 0.75L6.09808 4.5C6.29053 4.83333 6.04996 5.25 5.66506 5.25L1.33494 5.25C0.950036 5.25 0.709474 4.83333 0.901924 4.5L3.06699 0.75Z" fill="white" fill-opacity="0.7"/>
</svg>

After

Width:  |  Height:  |  Size: 329 B

@ -11,3 +11,4 @@ export * from './dashes'
export * from './env'
export * from './userAgent'
export * from './queries'
export * from './keyboardKeys'

@ -0,0 +1,3 @@
export enum KEYBOARD_KEYS {
Enter = 'Enter',
}

@ -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,

@ -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]

@ -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<SetStateAction<TCircleAnimation>>
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 (
<Svg>
<Svg
className={className}
size={svgSize}
>
<Circle
cx='50%'
cy='50%'
@ -72,7 +84,18 @@ export const CircleAnimationBar = ({
currentAnimationTime={currentAnimationTime}
animationPause={animationPause}
currentEpisodesPercent={currentEpisodesPercent}
strokeDashOffset={strokeDashOffset}
/>
{text && (
<text
x='50%'
y='50%'
dominantBaseline='middle'
textAnchor='middle'
>
{text}
</text>
)}
</Svg>
)
}

@ -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<SvgProps>`
--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<TCircle>`
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`};

@ -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<LexicsId> | LexicsConfig) => (
uniq(map(ids, (id) => Number(id)))
)

@ -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

@ -167,6 +167,17 @@ export const CardFrontside = ({
<Teams isMatchPage={isMatchPage}>
<Team isMatchPage={isMatchPage}>
<NameSignWrapper>
{isMatchPage && (
<TeamLogo
isMatchPage
id={team1.id}
logoUrl={team1.media?.logo_url}
nameAsTitle
altNameObj={team1}
sportType={sportType}
profileType={ProfileTypes.TEAMS}
/>
)}
<TeamName nameObj={team1} />
{team1InFavorites && <FavoriteSign />}
</NameSignWrapper>
@ -174,6 +185,17 @@ export const CardFrontside = ({
</Team>
<Team isMatchPage={isMatchPage}>
<NameSignWrapper>
{isMatchPage && (
<TeamLogo
isMatchPage
id={team2.id}
logoUrl={team2.media?.logo_url}
nameAsTitle
altNameObj={team2}
sportType={sportType}
profileType={ProfileTypes.TEAMS}
/>
)}
<TeamName nameObj={team2} />
{team2InFavorites && <FavoriteSign />}
</NameSignWrapper>

@ -89,9 +89,8 @@ export const PreviewWrapper = styled.div<TPreviewWrapper>`
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<CardProps>`
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.4;
display: ${({ isMatchPage }) => (isMatchPage ? 'none' : 'block')};
opacity: 0.2;
`
export const MatchTimeInfo = styled.div<CardProps>`
@ -124,11 +122,10 @@ export const MatchTimeInfo = styled.div<CardProps>`
${({ 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<CardProps>`
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<CardProps>`
${({ isMatchPage }) => (
isMatchPage
? css`
font-size: 18px;
line-height: 28px;
`
: ''
@ -284,7 +284,7 @@ export const TeamLogos = styled.div<CardProps>`
z-index: 1;
`
export const TeamLogo = styled(ProfileLogo)`
export const TeamLogo = styled(ProfileLogo)<CardProps>`
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<CardProps>`

@ -69,6 +69,7 @@ const getFullMatchChapters = (videos: Array<Video>) => {
duration: video.duration,
endMs,
endOffsetMs: endMs,
isFullMatchChapter: true,
period: video.period,
startMs: prevVideoEndMs,
startOffsetMs: 0,

@ -1,6 +1,6 @@
import { useEffect } from 'react'
import { useToggle } from 'hooks/useToggle'
import { useToggle, useObjectState } from 'hooks'
import type { Settings } from 'features/MatchPopup'
import { useMatchPopupStore } from 'features/MatchPopup'
@ -10,7 +10,17 @@ import { usePlayerLogger } from './usePlayerLogger'
import { useEpisodes } from './useEpisodes'
import { useChapters } from './useChapters'
const initPausedData = {
activeChapterIndex: 0,
activePlayer: 0 || 1,
playedProgress: 0,
}
export type PausedData = typeof initPausedData
export const useFinishedMatch = () => {
const [pausedData, setPausedData] = useObjectState<PausedData>(initPausedData)
const { setChapters, setSettings } = useMatchPopupStore()
const {
handlePlaylistClick,
@ -51,8 +61,10 @@ export const useFinishedMatch = () => {
onPlayingChange,
onPlaylistSelect,
openSettingsPopup,
pausedData,
playlists: matchPlaylists,
selectedPlaylist,
setEpisodesSettings,
setPausedData,
}
}

@ -1,9 +1,7 @@
import { Fragment, useState } from 'react'
import { Fragment } from 'react'
import isEmpty from 'lodash/isEmpty'
import type { TCircleAnimation } from 'features/CircleAnimationBar'
import { initialCircleAnimation } from 'features/CircleAnimationBar'
import { MatchSidePlaylists } from 'features/MatchSidePlaylists'
import { MultiSourcePlayer } from 'features/MultiSourcePlayer'
@ -16,7 +14,6 @@ import { MatchDescription } from '../MatchDescription'
import { useMatchPageStore } from '../../store'
export const FinishedMatch = () => {
const [circleAnimation, setCircleAnimation] = useState<TCircleAnimation>(initialCircleAnimation)
const {
access,
isOpenFiltersPopup,
@ -28,8 +25,10 @@ export const FinishedMatch = () => {
isSettingsPopupOpen,
onPlayingChange,
onPlaylistSelect,
pausedData,
selectedPlaylist,
setEpisodesSettings,
setPausedData,
} = useFinishedMatch()
return (
@ -51,8 +50,9 @@ export const FinishedMatch = () => {
{!isEmpty(chapters) && (
<Fragment>
<MultiSourcePlayer
pausedData={pausedData}
setPausedData={setPausedData}
access={access}
setCircleAnimation={setCircleAnimation}
isOpenPopup={isOpenFiltersPopup}
chapters={chapters}
onPlayingChange={onPlayingChange}
@ -64,8 +64,6 @@ export const FinishedMatch = () => {
</Container>
<MatchSidePlaylists
setCircleAnimation={setCircleAnimation}
circleAnimation={circleAnimation}
selectedPlaylist={selectedPlaylist}
onSelect={onPlaylistSelect}
/>

@ -3,8 +3,11 @@ import find from 'lodash/find'
import reduce from 'lodash/reduce'
import concat from 'lodash/concat'
import type { Episodes } from 'requests/getMatchPlaylists'
import type { MatchInfo } from 'requests/getMatchInfo'
import type {
MatchInfo,
MatchScore,
Episodes,
} from 'requests'
import type { Chapters, Chapter } from 'features/StreamPlayer/types'
@ -13,16 +16,25 @@ import { FULL_GAME_KEY } from '../../helpers/buildPlaylists'
export const FULL_MATCH_BOUNDARY = '0'
type GetFullMatchChaptersArgs = {
matchScore?: MatchScore,
playlist: MatchPlaylistOption,
profile: MatchInfo,
url: string,
}
/**
* Формирует эпизоды плейлиста Полный матч
* API не выдает полный матч как плейлист, формируем на фронте
* */
const getFullMatchChapters = (
profile: MatchInfo,
url: string,
playlist: MatchPlaylistOption,
) => {
const bound = find(profile?.video_bounds, { h: FULL_MATCH_BOUNDARY })
const getFullMatchChapters = ({
matchScore,
playlist,
profile,
url,
}: GetFullMatchChaptersArgs) => {
const videoBounds = matchScore?.video_bounds || profile?.video_bounds
const bound = find(videoBounds, { h: FULL_MATCH_BOUNDARY })
const durationMs = (bound && !profile?.live)
? ((playlist.duration ?? 0) - Number(bound.s)) * 1000
@ -42,14 +54,22 @@ const getFullMatchChapters = (
]
}
type GetPlaylistChaptersArgs = {
episodes: Episodes,
matchScore?: MatchScore,
profile: MatchInfo,
url: string,
}
/**
* Формирует эпизоды плейлистов матча и игроков
* */
const getPlaylistChapters = (
profile: MatchInfo,
url: string,
episodes: Episodes,
) => reduce(
const getPlaylistChapters = ({
episodes,
matchScore,
profile,
url,
}: GetPlaylistChaptersArgs) => reduce(
episodes,
(
acc: Chapters,
@ -58,7 +78,8 @@ const getPlaylistChapters = (
) => {
if (episode.s >= episode.e) return acc
const bound = find(profile?.video_bounds, { h: String(episode.h) })
const videoBounds = matchScore?.video_bounds || profile?.video_bounds
const bound = find(videoBounds, { h: String(episode.h) })
const boundStart = bound ? Number(bound.s) : 0
const episodeDuration = (episode.e - episode.s) * 1000
@ -79,6 +100,7 @@ const getPlaylistChapters = (
)
type Args = {
matchScore?: MatchScore,
profile: MatchInfo,
selectedPlaylist?: PlaylistOption,
url: string,
@ -88,21 +110,24 @@ type Args = {
* Формирует список эпизодов из выбранного плейлиста для плеера
*/
export const buildChapters = ({
matchScore,
profile,
selectedPlaylist,
url,
}: Args): Chapters => {
if (!selectedPlaylist) return []
if (selectedPlaylist.id === FULL_GAME_KEY) {
return getFullMatchChapters(
return getFullMatchChapters({
matchScore,
playlist: selectedPlaylist,
profile,
url,
selectedPlaylist,
)
})
}
return getPlaylistChapters(
return getPlaylistChapters({
episodes: selectedPlaylist.episodes,
matchScore,
profile,
url,
selectedPlaylist.episodes,
)
})
}

@ -16,8 +16,6 @@ import { usePlaylistLogger } from './usePlaylistLogger'
export const useLiveMatch = () => {
const {
handlePlaylistClick,
isPlayFilterEpisodes,
playNextEpisode,
profile,
selectedPlaylist,
setFullMatchPlaylistDuration,
@ -65,17 +63,13 @@ export const useLiveMatch = () => {
}
handlePlaylistClick(playlist, e)
}
return {
chapters,
isPlayFilterEpisodes,
onDurationChange,
onPlayerProgressChange,
onPlayingChange,
onPlaylistSelect,
playNextEpisode,
resume: resume ?? fromStartIfStreamPaused,
selectedPlaylist,
streamUrl: `${API_ROOT}/video/stream/${sportType}/${matchId}.m3u8`,
}
}

@ -1,7 +1,10 @@
import { useMemo } from 'react'
import { useQueryClient } from 'react-query'
import { querieKeys } from 'config'
import type { PlaylistOption } from 'features/MatchPage/types'
import type { MatchInfo } from 'requests/getMatchInfo'
import type { MatchInfo, MatchScore } from 'requests'
import { buildChapters } from '../helpers'
@ -16,13 +19,19 @@ export const useChapters = ({
selectedPlaylist,
url,
}: Args) => {
const client = useQueryClient()
const matchScore = client.getQueryData<MatchScore>(querieKeys.matchScore)
const chapters = useMemo(
() => buildChapters({
matchScore,
profile,
selectedPlaylist,
url,
}),
[
matchScore,
profile,
selectedPlaylist,
url,

@ -1,9 +1,7 @@
import { Fragment, useState } from 'react'
import { Fragment } from 'react'
import isEmpty from 'lodash/isEmpty'
import type { TCircleAnimation } from 'features/CircleAnimationBar'
import { initialCircleAnimation } from 'features/CircleAnimationBar'
import { useMatchPageStore } from 'features/MatchPage/store'
import { StreamPlayer } from 'features/StreamPlayer'
import { YoutubePlayer } from 'features/StreamPlayer/components/YoutubePlayer'
@ -15,8 +13,6 @@ import { useLiveMatch } from './hooks'
import { MatchDescription } from '../MatchDescription'
export const LiveMatch = () => {
const [circleAnimation, setCircleAnimation] = useState<TCircleAnimation>(initialCircleAnimation)
const {
profile,
selectedPlaylist,
@ -42,12 +38,10 @@ export const LiveMatch = () => {
onProgressChange={onPlayerProgressChange}
resumeFrom={resume}
url={streamUrl}
setCircleAnimation={setCircleAnimation}
/>
) : (
!isEmpty(chapters) && (
<StreamPlayer
setCircleAnimation={setCircleAnimation}
onDurationChange={onDurationChange}
onPlayingChange={onPlayingChange}
onProgressChange={onPlayerProgressChange}
@ -61,8 +55,6 @@ export const LiveMatch = () => {
</Container>
<MatchSidePlaylists
setCircleAnimation={setCircleAnimation}
circleAnimation={circleAnimation}
onSelect={onPlaylistSelect}
selectedPlaylist={selectedPlaylist}
/>

@ -57,7 +57,7 @@ export const MatchDescription = () => {
const { data: queryScore } = useQuery({
queryFn: async () => {
if (profile?.live && !isScoreHidden) {
if (profile?.live) {
const score = await getMatchScore({ profileId, sportType })
return score
}

@ -1,11 +0,0 @@
import find from 'lodash/find'
import type { MatchInfo } from 'requests/getMatchInfo'
import { FULL_MATCH_BOUNDARY } from 'features/MatchPage/components/LiveMatch/helpers'
export const calculateDuration = (profile: MatchInfo) => {
const bound = find(profile?.video_bounds, { h: FULL_MATCH_BOUNDARY })
if (!bound) return 0
return Number(bound.e) - Number(bound.s)
}

@ -0,0 +1,53 @@
import head from 'lodash/head'
import last from 'lodash/last'
import inRange from 'lodash/inRange'
import type { VideoBounds } from 'requests'
export const getHalfTime = (videoBounds: VideoBounds, currentTime: number) => {
const firstBound = head(videoBounds)
const lastBound = last(videoBounds)
const matchSecond = (Number(firstBound?.s) || 0) + currentTime
let period = 1
let second = 1
if (firstBound === lastBound || matchSecond < (Number(videoBounds[1]?.s || 0))) {
return {
period,
second,
}
}
if (lastBound?.e && matchSecond > (Number(lastBound.e))) {
return {}
}
for (let i = 1; i < videoBounds.length; i++) {
const { e, s } = videoBounds[i]
if (inRange(
matchSecond,
Number(s),
Number(e || 1e5) + 1,
)) {
period = i
second = matchSecond - Number(videoBounds[i].s)
break
} else if (
videoBounds[i + 1] && inRange(
matchSecond,
Number(e) + 1,
Number(videoBounds[i + 1].s),
)) {
period = i + 1
break
}
}
return {
period,
second,
}
}

@ -9,6 +9,9 @@ import filter from 'lodash/filter'
import isEmpty from 'lodash/isEmpty'
import { useAuthStore } from 'features/AuthStore'
import { Tabs } from 'features/MatchSidePlaylists/config'
import { initialCircleAnimation } from 'features/CircleAnimationBar'
import type { TCircleAnimation } from 'features/CircleAnimationBar'
import { PAGES } from 'config/pages'
@ -17,8 +20,7 @@ import { getMatchInfo } from 'requests/getMatchInfo'
import { getViewMatchDuration } from 'requests/getViewMatchDuration'
import { getLandingStatus } from 'requests/getLandingStatus'
import { usePageParams } from 'hooks/usePageParams'
import { useToggle } from 'hooks/useToggle'
import { usePageParams, useToggle } from 'hooks'
import { redirectToUrl } from 'helpers/redirectToUrl'
import { parseDate } from 'helpers/parseDate'
@ -27,6 +29,31 @@ import { useTournamentData } from './useTournamentData'
import { useMatchData } from './useMatchData'
import { useFiltersPopup } from './useFitersPopup'
import { useTabEvents } from './useTabEvents'
import { useTeamsStats } from './useTeamsStats'
import { useStatsTab } from './useStatsTab'
import { usePlayersStats } from './usePlayersStats'
type PlayingData = {
player: {
id: number | null,
paramId: number | null,
},
team: {
id: number | null,
paramId: number | null,
},
}
const initPlayingData: PlayingData = {
player: {
id: null,
paramId: null,
},
team: {
id: null,
paramId: null,
},
}
const ACCESS_TIME = 60
@ -34,6 +61,15 @@ export const useMatchPage = () => {
const [matchProfile, setMatchProfile] = useState<MatchInfo>(null)
const [watchAllEpisodesTimer, setWatchAllEpisodesTimer] = useState(false)
const [access, setAccess] = useState(true)
const [playingProgress, setPlayingProgress] = useState(0)
const [playingData, setPlayingData] = useState<PlayingData>(initPlayingData)
const [plaingOrder, setPlaingOrder] = useState(0)
const [isPlayFilterEpisodes, setIsPlayingFiltersEpisodes] = useState(false)
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.WATCH)
const [circleAnimation, setCircleAnimation] = useState<TCircleAnimation>(initialCircleAnimation)
const isStatsTab = selectedTab === Tabs.STATS
const { profileId: matchId, sportType } = usePageParams()
useEffect(() => {
@ -147,13 +183,64 @@ export const useMatchPage = () => {
userInfo,
])
const disablePlayingEpisodes = () => {
setIsPlayingFiltersEpisodes(false)
setWatchAllEpisodesTimer(false)
setCircleAnimation(initialCircleAnimation)
}
const {
circleAnimation: statsCircleAnimation,
filteredEvents: statsFilteredEvents,
isPlayersStatsFetching,
isPlayFilterEpisodes: isStatsPlayFilterEpisodes,
isTeamsStatsFetching,
plaingOrder: statsPlaingOrder,
playEpisodes: playStatsEpisodes,
playNextEpisode: playStatsNextEpisode,
setCircleAnimation: setStatsCircleAnimation,
setIsPlayersStatsFetching,
setIsPlayingFiltersEpisodes: setStatsIsPlayinFiltersEpisodes,
setIsTeamsStatsFetching,
setPlaingOrder: setStatsPlaingOrder,
setWatchAllEpisodesTimer: setStatsWatchAllEpisodesTimer,
statsType,
toggleStatsType,
watchAllEpisodesTimer: statsWatchAllEpisodesTimer,
} = useStatsTab({
disablePlayingEpisodes,
handlePlaylistClick,
matchProfile,
selectedPlaylist,
})
const { teamsStats } = useTeamsStats({
matchProfile,
playingProgress,
selectedPlaylist,
setIsTeamsStatsFetching,
statsType,
})
const {
isEmptyPlayersStats,
playersData,
playersStats,
} = usePlayersStats({
matchProfile,
playingProgress,
selectedPlaylist,
setIsPlayersStatsFetching,
statsType,
})
const isStarted = useMemo(() => (
profile?.date
? parseDate(profile.date) < new Date()
: true
), [profile?.date])
const { tournamentData } = useTournamentData(matchProfile?.tournament.id ?? null)
const { tournamentData } = useTournamentData(matchProfile)
const filteredEvents = useMemo(() => {
switch (true) {
@ -168,9 +255,6 @@ export const useMatchPage = () => {
}
}, [events, filters])
const [plaingOrder, setPlaingOrder] = useState(0)
const [isPlayFilterEpisodes, setIsPlayinFiltersEpisodes] = useState(false)
const {
activeStatus,
episodesToPlay,
@ -182,16 +266,12 @@ export const useMatchPage = () => {
setUnreversed,
} = useTabEvents({ events: filteredEvents, profile })
useEffect(() => {
if (plaingOrder > episodesToPlay.length) setPlaingOrder(0)
}, [plaingOrder, episodesToPlay])
const playNextEpisode = (order?: number) => {
const isLastEpisode = plaingOrder === episodesToPlay.length
const currentOrder = order === 0 ? order : plaingOrder
if (isLastEpisode) {
setIsPlayinFiltersEpisodes(false)
setIsPlayingFiltersEpisodes(false)
return
}
@ -199,11 +279,16 @@ export const useMatchPage = () => {
setPlaingOrder(currentOrder + 1)
}
const playEpisodes = () => {
setPlayingData(initPlayingData)
setStatsWatchAllEpisodesTimer(true)
setStatsIsPlayinFiltersEpisodes(false)
setStatsCircleAnimation(initialCircleAnimation)
if (!watchAllEpisodesTimer) {
setWatchAllEpisodesTimer(true)
}
setIsPlayinFiltersEpisodes(true)
setIsPlayingFiltersEpisodes(true)
if (matchProfile?.live) {
handlePlaylistClick({
@ -216,10 +301,6 @@ export const useMatchPage = () => {
}
}
const disablePlayingEpisodes = () => {
setIsPlayinFiltersEpisodes(false)
}
return {
access,
activeEvents,
@ -229,44 +310,63 @@ export const useMatchPage = () => {
allActionsToggle,
allPlayersToggle,
applyFilters,
circleAnimation: isStatsTab ? statsCircleAnimation : circleAnimation,
closePopup,
countOfFilters,
disablePlayingEpisodes,
events,
filteredEvents,
filteredEvents: isStatsTab ? statsFilteredEvents : filteredEvents,
handlePlaylistClick,
hideProfileCard,
isAllActionsChecked,
isEmptyFilters,
isEmptyPlayersStats,
isFirstTeamPlayersChecked,
isLiveMatch,
isOpenFiltersPopup,
isPlayFilterEpisodes,
isPlayFilterEpisodes: isStatsTab ? isStatsPlayFilterEpisodes : isPlayFilterEpisodes,
isPlayersStatsFetching,
isSecondTeamPlayersChecked,
isStarted,
isTeamsStatsFetching,
likeImage,
likeToggle,
matchPlaylists,
plaingOrder,
plaingOrder: isStatsTab ? statsPlaingOrder : plaingOrder,
playEpisodes,
playNextEpisode,
playNextEpisode: isStatsTab ? playStatsNextEpisode : playNextEpisode,
playStatsEpisodes,
playersData,
playersStats,
playingData,
playingProgress,
profile,
profileCardShown,
reversedGroupEvents,
selectedPlaylist,
selectedTab,
setCircleAnimation: isStatsTab ? setStatsCircleAnimation : setCircleAnimation,
setFullMatchPlaylistDuration,
setIsPlayinFiltersEpisodes,
setPlaingOrder,
setIsPlayingFiltersEpisodes: isStatsTab
? setStatsIsPlayinFiltersEpisodes
: setIsPlayersStatsFetching,
setPlaingOrder: isStatsTab ? setStatsPlaingOrder : setPlaingOrder,
setPlayingData,
setPlayingProgress,
setReversed,
setSelectedTab,
setUnreversed,
setWatchAllEpisodesTimer,
setWatchAllEpisodesTimer: isStatsTab ? setStatsWatchAllEpisodesTimer : setWatchAllEpisodesTimer,
showProfileCard,
statsType,
teamsStats,
toggleActiveEvents,
toggleActivePlayers,
togglePopup,
toggleStatsType,
tournamentData,
uniqEvents,
user,
watchAllEpisodesTimer,
watchAllEpisodesTimer: isStatsTab ? statsWatchAllEpisodesTimer : watchAllEpisodesTimer,
}
}

@ -6,16 +6,18 @@ import {
import debounce from 'lodash/debounce'
import { MatchInfo } from 'requests/getMatchInfo'
import type { MatchInfo } from 'requests/getMatchInfo'
import { usePageParams } from 'hooks/usePageParams'
import { useInterval } from 'hooks/useInterval'
import { FULL_GAME_KEY } from 'features/MatchPage/helpers/buildPlaylists'
import { useDuration } from 'features/MultiSourcePlayer/hooks/useDuration'
import { useMatchPopupStore } from 'features/MatchPopup'
import { useMatchPlaylists } from './useMatchPlaylists'
import { useEvents } from './useEvents'
import { initialPlaylist } from './useSelectedPlaylist'
const MATCH_DATA_POLL_INTERVAL = 60000
const MATCH_PLAYLISTS_DELAY = 5000
@ -32,6 +34,7 @@ export const useMatchData = (profile: MatchInfo) => {
setFullMatchPlaylistDuration,
setSelectedPlaylist,
} = useMatchPlaylists(profile)
const { events, fetchMatchEvents } = useEvents()
const fetchPlaylistsDebounced = useMemo(
@ -41,7 +44,7 @@ export const useMatchData = (profile: MatchInfo) => {
const chaptersDuration = useDuration(chapters) / 1000
const fullMatchDuration = matchDuration
useEffect(() => {
if (!profile) return
if (!profile || (profile.live && Number(profile.c_match_calc_status) <= 1)) return
fetchMatchPlaylists({
fullMatchDuration,
id: matchId,
@ -73,20 +76,20 @@ export const useMatchData = (profile: MatchInfo) => {
})
useEffect(() => {
if (profile?.live) {
if (profile?.live && Number(profile.c_match_calc_status) > 1) {
start()
} else {
stop()
}
}, [profile?.live, start, stop])
}, [profile?.live, profile?.c_match_calc_status, start, stop])
useEffect(() => {
selectedPlaylist?.id === 'full_game' && setMatchDuration(chaptersDuration)
selectedPlaylist?.id === FULL_GAME_KEY && setMatchDuration(chaptersDuration)
// eslint-disable-next-line
}, [profile, chaptersDuration])
useEffect(() => {
setSelectedPlaylist(matchPlaylists.match[0])
setSelectedPlaylist(matchPlaylists.match[0] || initialPlaylist)
// eslint-disable-next-line
}, [matchId])
@ -96,5 +99,6 @@ export const useMatchData = (profile: MatchInfo) => {
matchPlaylists,
selectedPlaylist,
setFullMatchPlaylistDuration,
setSelectedPlaylist,
}
}

@ -17,7 +17,7 @@ import type { Playlists } from 'features/MatchPage/types'
import { buildPlaylists, FULL_GAME_KEY } from 'features/MatchPage/helpers/buildPlaylists'
import { usePlaylistLexics } from './usePlaylistLexics'
import { useSelectedPlaylist } from './useSelectedPlaylist'
import { initialPlaylist, useSelectedPlaylist } from './useSelectedPlaylist'
type ArgsFetchMatchPlaylists = {
fullMatchDuration: number,
@ -71,7 +71,7 @@ export const useMatchPlaylists = (profile: MatchInfo) => {
useEffect(() => {
if (selectedPlaylist?.id !== FULL_GAME_KEY) return
setSelectedPlaylist(matchPlaylists?.match[0])
setSelectedPlaylist(matchPlaylists?.match[0] || initialPlaylist)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
matchPlaylists?.match,

@ -0,0 +1,201 @@
import type { Dispatch, SetStateAction } from 'react'
import {
useMemo,
useEffect,
useState,
} from 'react'
import { useQueryClient } from 'react-query'
import throttle from 'lodash/throttle'
import isEmpty from 'lodash/isEmpty'
import every from 'lodash/every'
import find from 'lodash/find'
import isUndefined from 'lodash/isUndefined'
import { querieKeys } from 'config'
import type { MatchScore } from 'requests'
import {
MatchInfo,
PlayersStats,
Player,
getPlayersStats,
getMatchParticipants,
} from 'requests'
import { useObjectState, usePageParams } from 'hooks'
import type{ PlaylistOption } from 'features/MatchPage/types'
import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/config'
import { FULL_GAME_KEY } from 'features/MatchPage/helpers/buildPlaylists'
import { getHalfTime } from 'features/MatchPage/helpers/getHalfTime'
const REQUEST_DELAY = 3000
const STATS_POLL_INTERVAL = 30000
type UsePlayersStatsArgs = {
matchProfile: MatchInfo,
playingProgress: number,
selectedPlaylist?: PlaylistOption,
setIsPlayersStatsFetching: Dispatch<SetStateAction<boolean>>,
statsType: StatsType,
}
type PlayersData = {
team1: Array<Player>,
team2: Array<Player>,
}
export const usePlayersStats = ({
matchProfile,
playingProgress,
selectedPlaylist,
setIsPlayersStatsFetching,
statsType,
}: UsePlayersStatsArgs) => {
const [playersStats, setPlayersStats] = useObjectState<Record<string, PlayersStats>>({})
const [playersData, setPlayersData] = useState<PlayersData>({ team1: [], team2: [] })
const {
profileId: matchId,
sportName,
sportType,
} = usePageParams()
const client = useQueryClient()
const matchScore = client.getQueryData<MatchScore>(querieKeys.matchScore)
const isCurrentStats = statsType === StatsType.CURRENT_STATS
const isEmptyPlayersStats = (teamId: number) => (
isEmpty(playersStats[teamId])
|| every(playersStats[teamId], isEmpty)
|| isEmpty(playersData[matchProfile?.team1.id === teamId ? 'team1' : 'team2'])
)
const fetchPlayers = useMemo(() => throttle(async (second?: number) => {
const videoBounds = matchScore?.video_bounds || matchProfile?.video_bounds
if (
!matchProfile?.team1.id
|| !matchProfile?.team2.id
|| !videoBounds
) return null
try {
return getMatchParticipants({
matchId,
sportType,
...(!isUndefined(second) && getHalfTime(videoBounds, second)),
})
} catch (e) {
return Promise.reject(e)
}
}, REQUEST_DELAY), [
matchId,
matchProfile?.team1.id,
matchProfile?.team2.id,
matchProfile?.video_bounds,
matchScore?.video_bounds,
sportType,
])
const fetchPlayersStats = useMemo(() => (async (team: 'team1' | 'team2', second?: number) => {
const videoBounds = matchScore?.video_bounds || matchProfile?.video_bounds
if (!sportName || !matchProfile?.[team].id || !videoBounds) return null
try {
return getPlayersStats({
matchId,
sportName,
teamId: matchProfile[team].id,
...(!isUndefined(second) && getHalfTime(videoBounds, second)),
})
} 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) => {
if (
selectedPlaylist?.id !== FULL_GAME_KEY
|| (matchProfile?.live && Number(matchProfile.c_match_calc_status) <= 1)
) return
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), [
selectedPlaylist?.id,
fetchPlayers,
fetchPlayersStats,
setPlayersStats,
matchProfile?.team1.id,
matchProfile?.team2.id,
matchProfile?.live,
matchProfile?.c_match_calc_status,
setIsPlayersStatsFetching,
])
useEffect(() => {
let interval: NodeJS.Timeout
if (!isCurrentStats) {
fetchData()
}
if (matchProfile?.live && !isCurrentStats) {
interval = setInterval(() => {
fetchData()
}, STATS_POLL_INTERVAL)
}
return () => clearInterval(interval)
}, [
fetchData,
isCurrentStats,
matchProfile?.live,
])
useEffect(() => {
if (isCurrentStats) {
fetchData(playingProgress)
}
}, [
fetchData,
playingProgress,
isCurrentStats,
matchProfile?.live,
])
return {
isEmptyPlayersStats,
playersData,
playersStats,
}
}

@ -1,6 +1,8 @@
import type { MouseEvent } from 'react'
import { useState, useCallback } from 'react'
import { indexLexics } from 'config/lexics/indexLexics'
import { getPlayerPlaylists } from 'requests/getPlayerPlaylists'
import { usePageParams } from 'hooks/usePageParams'
@ -11,10 +13,19 @@ import {
PlaylistTypes,
} from 'features/MatchPage/types'
import { defaultSettings } from 'features/MatchPopup/types'
import { FULL_GAME_KEY } from 'features/MatchPage/helpers/buildPlaylists'
export const initialPlaylist = {
duration: 0,
episodes: [],
id: FULL_GAME_KEY,
lexic: indexLexics.full_game,
type: 0,
}
export const useSelectedPlaylist = () => {
const { profileId: matchId, sportType } = usePageParams()
const [selectedPlaylist, setSelectedPlaylist] = useState<PlaylistOption>()
const [selectedPlaylist, setSelectedPlaylist] = useState<PlaylistOption>(initialPlaylist)
const fetchPlayerEpisodes = useCallback((playlistOption: PlayerPlaylistOption) => (
getPlayerPlaylists({

@ -0,0 +1,144 @@
import { useState, useEffect } from 'react'
import map from 'lodash/map'
import isEqual from 'lodash/isEqual'
import type {
Episode,
Episodes,
Events,
MatchInfo,
} from 'requests'
import type { EventPlaylistOption, PlaylistOption } from 'features/MatchPage/types'
import type { TCircleAnimation } from 'features/CircleAnimationBar'
import { initialCircleAnimation } from 'features/CircleAnimationBar'
import { PlaylistTypes } from 'features/MatchPage/types'
import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/config'
type UseStatsTabArgs = {
disablePlayingEpisodes: () => void,
handlePlaylistClick: (playlist: PlaylistOption) => void,
matchProfile: MatchInfo,
selectedPlaylist?: PlaylistOption,
}
type PlayNextEpisodeArgs = {
episodesToPlay?: Array<EventPlaylistOption>,
order?: number,
}
const EPISODE_TIMESTAMP_OFFSET = 0.001
const addOffset = ({
e,
h,
s,
}: Episode) => ({
e: e + EPISODE_TIMESTAMP_OFFSET,
h,
s: s + EPISODE_TIMESTAMP_OFFSET,
})
export const useStatsTab = ({
disablePlayingEpisodes,
handlePlaylistClick,
matchProfile,
selectedPlaylist,
}: UseStatsTabArgs) => {
const [statsType, setStatsType] = useState<StatsType>(StatsType.FINAL_STATS)
const [isPlayersStatsFetching, setIsPlayersStatsFetching] = useState(false)
const [isTeamsStatsFetching, setIsTeamsStatsFetching] = useState(false)
const [stateEpisodesToPlay, setEpisodesToPlay] = useState<Array<EventPlaylistOption>>([])
const [filteredEvents, setFilteredEvents] = useState<Events>([])
const [plaingOrder, setPlaingOrder] = useState(0)
const [isPlayFilterEpisodes, setIsPlayingFiltersEpisodes] = useState(false)
const [watchAllEpisodesTimer, setWatchAllEpisodesTimer] = useState(false)
const [circleAnimation, setCircleAnimation] = useState<TCircleAnimation>(initialCircleAnimation)
const isFinalStatsType = statsType === StatsType.FINAL_STATS
const toggleStatsType = () => {
const newStatsType = isFinalStatsType ? StatsType.CURRENT_STATS : StatsType.FINAL_STATS
setStatsType(newStatsType)
setIsTeamsStatsFetching(true)
setIsPlayersStatsFetching(true)
}
const getEpisodesToPlay = (episodes: Episodes) => map(episodes, (episode, i) => ({
episodes: [
/** При проигрывании нового эпизода с такими же e и s, как у текущего
воспроизведение начинается не с начала, чтобы пофиксить это добавляем
небольшой оффсет
*/
isEqual(episode, selectedPlaylist?.episodes[0])
? addOffset(episode)
: episode,
],
id: i,
type: PlaylistTypes.EVENT,
})) as Array<EventPlaylistOption>
const playNextEpisode = ({
order,
episodesToPlay = stateEpisodesToPlay,
}: PlayNextEpisodeArgs = {}) => {
const currentOrder = order === 0 ? order : plaingOrder
const isLastEpisode = currentOrder === episodesToPlay.length
if (isLastEpisode) {
setPlaingOrder(0)
setIsPlayingFiltersEpisodes(false)
return
}
if (currentOrder !== 0) {
handlePlaylistClick(episodesToPlay[currentOrder])
}
setPlaingOrder(currentOrder + 1)
}
const playEpisodes = (episodes: Episodes) => {
disablePlayingEpisodes()
const episodesToPlay = getEpisodesToPlay(episodes)
setEpisodesToPlay(episodesToPlay)
setFilteredEvents(episodes as Events)
setWatchAllEpisodesTimer(true)
setIsPlayingFiltersEpisodes(true)
handlePlaylistClick(episodesToPlay[0])
playNextEpisode({ episodesToPlay, order: 0 })
}
useEffect(() => {
if (matchProfile?.live) {
setStatsType(StatsType.CURRENT_STATS)
}
}, [matchProfile?.live])
return {
circleAnimation,
filteredEvents,
isPlayFilterEpisodes,
isPlayersStatsFetching,
isTeamsStatsFetching,
plaingOrder,
playEpisodes,
playNextEpisode,
setCircleAnimation,
setIsPlayersStatsFetching,
setIsPlayingFiltersEpisodes,
setIsTeamsStatsFetching,
setPlaingOrder,
setWatchAllEpisodesTimer,
statsType,
toggleStatsType,
watchAllEpisodesTimer,
}
}

@ -0,0 +1,113 @@
import type { Dispatch, SetStateAction } from 'react'
import {
useEffect,
useState,
useMemo,
} from 'react'
import { useQueryClient } from 'react-query'
import throttle from 'lodash/throttle'
import isUndefined from 'lodash/isUndefined'
import { querieKeys } from 'config'
import type { MatchInfo, MatchScore } from 'requests'
import { getTeamsStats, TeamStatItem } from 'requests'
import { usePageParams } from 'hooks'
import type { PlaylistOption } from 'features/MatchPage/types'
import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/config'
import { FULL_GAME_KEY } from 'features/MatchPage/helpers/buildPlaylists'
import { getHalfTime } from 'features/MatchPage/helpers/getHalfTime'
const REQUEST_DELAY = 3000
const STATS_POLL_INTERVAL = 30000
type UseTeamsStatsArgs = {
matchProfile: MatchInfo,
playingProgress: number,
selectedPlaylist?: PlaylistOption,
setIsTeamsStatsFetching: Dispatch<SetStateAction<boolean>>,
statsType: StatsType,
}
export const useTeamsStats = ({
matchProfile,
playingProgress,
selectedPlaylist,
setIsTeamsStatsFetching,
statsType,
}: UseTeamsStatsArgs) => {
const [teamsStats, setTeamsStats] = useState<{
[teamId: string]: Array<TeamStatItem>,
}>({})
const { profileId: matchId, sportName } = usePageParams()
const client = useQueryClient()
const matchScore = client.getQueryData<MatchScore>(querieKeys.matchScore)
const isCurrentStats = statsType === StatsType.CURRENT_STATS
const fetchTeamsStats = useMemo(() => throttle(async (second?: number) => {
const videoBounds = matchScore?.video_bounds || matchProfile?.video_bounds
if (
!sportName
|| selectedPlaylist?.id !== FULL_GAME_KEY
|| !videoBounds
|| (matchProfile?.live && Number(matchProfile.c_match_calc_status) <= 1)
) return
try {
const data = await getTeamsStats({
matchId,
sportName,
...(!isUndefined(second) && getHalfTime(videoBounds, second)),
})
setTeamsStats(data)
setIsTeamsStatsFetching(false)
// eslint-disable-next-line no-empty
} catch (e) {}
}, REQUEST_DELAY), [
matchProfile?.video_bounds,
matchProfile?.c_match_calc_status,
matchProfile?.live,
matchScore?.video_bounds,
selectedPlaylist?.id,
matchId,
setIsTeamsStatsFetching,
sportName,
])
useEffect(() => {
let interval: NodeJS.Timeout
if (!isCurrentStats) {
fetchTeamsStats()
}
if (matchProfile?.live && !isCurrentStats) {
interval = setInterval(() => {
fetchTeamsStats()
}, STATS_POLL_INTERVAL)
}
return () => clearInterval(interval)
}, [fetchTeamsStats, matchProfile?.live, isCurrentStats])
useEffect(() => {
if (isCurrentStats) {
fetchTeamsStats(playingProgress)
}
}, [fetchTeamsStats, playingProgress, isCurrentStats])
return {
statsType,
teamsStats,
}
}

@ -12,6 +12,7 @@ import sortBy from 'lodash/sortBy'
import type { Match } from 'features/Matches'
import { prepareMatches } from 'features/Matches/helpers/prepareMatches'
import type { MatchInfo } from 'requests'
import { getTournamentMatches } from 'requests'
import { parseDate } from 'helpers/parseDate'
@ -20,14 +21,18 @@ import { usePageParams } from 'hooks/usePageParams'
import { TournamentData } from '../../types'
export const useTournamentData = (tournamentId: number | null) => {
export const useTournamentData = (matchProfile: MatchInfo) => {
const { sportType } = usePageParams()
const [tournamentMatches, setTournamentMatches] = useState<Array<Match>>([])
const [matchDates, setMatchDates] = useState<Array<string>>([])
const tournamentId = matchProfile?.tournament.id ?? null
useEffect(() => {
if (!isNull(tournamentId)) {
if (matchProfile?.live && Number(matchProfile.c_match_calc_status) <= 1) return
(async () => {
const matchesBySection = await getTournamentMatches({
limit: 1000,
@ -44,7 +49,12 @@ export const useTournamentData = (tournamentId: number | null) => {
setTournamentMatches(sortBy(prepareMatches(matchesBySection.broadcast), ['date']))
})()
}
}, [tournamentId, sportType])
}, [
tournamentId,
sportType,
matchProfile?.live,
matchProfile?.c_match_calc_status,
])
const tournamentData: TournamentData = useMemo(() => ({
matchDates,

@ -0,0 +1,18 @@
import styled from 'styled-components/macro'
import { CircleAnimationBar as CircleAnimationBarBase } from 'features/CircleAnimationBar'
export const CircleAnimationBar = styled(CircleAnimationBarBase)`
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
circle {
stroke: #4086C6;
}
text {
fill: ${({ theme }) => theme.colors.white};
}
`

@ -1,11 +1,18 @@
import { ProfileTypes } from 'config'
import { createPortal } from 'react-dom'
import { isMobileDevice, ProfileTypes } from 'config'
import type { Event, Team } from 'requests'
import { usePageParams } from 'hooks/usePageParams'
import {
useTooltip,
usePageParams,
useModalRoot,
} from 'hooks'
import { Name } from 'features/Name'
import { T9n } from 'features/T9n'
import { useLexicsStore } from 'features/LexicsStore'
import {
Avatar,
@ -17,6 +24,7 @@ import {
EventTime,
EventLike,
} from '../TabEvents/styled'
import { Tooltip } from '../TabStats/styled'
type Props = {
active?: boolean,
@ -36,6 +44,16 @@ export const EventButton = ({
team,
}: Props) => {
const { sportType } = usePageParams()
const {
isTooltipShown,
onMouseLeave,
onMouseOver,
tooltipStyle,
tooltipText,
} = useTooltip()
const modalRoot = useModalRoot()
const { suffix } = useLexicsStore()
const {
c: clearTime,
l: lexica,
@ -81,11 +99,28 @@ export const EventButton = ({
<T9n t={lexica} />
{(score1 || score2) && ` (${score1}-${score2})`}
</Title>
<SubTitle>
<SubTitle
// eslint-disable-next-line react/jsx-props-no-spreading
{...!isMobileDevice && {
onMouseLeave,
onMouseOver: onMouseOver({
horizontalPosition: 'right',
indent: 15,
tooltipText: nameObj?.[`name_${suffix}`] || '',
}),
}}
>
{playerName?.num}{' '}
{(playerId || teamId) && nameObj && <Name nameObj={nameObj} />}
</SubTitle>
</EventDesc>
</EventInfo>
{isTooltipShown && modalRoot.current && createPortal(
<Tooltip style={tooltipStyle}>
{tooltipText}
</Tooltip>,
modalRoot.current,
)}
</Button>
)
}

@ -1,8 +1,8 @@
import { useMemo } from 'react'
import type { ForwardedRef } from 'react'
import { forwardRef } from 'react'
import styled, { css } from 'styled-components/macro'
import filter from 'lodash/filter'
import isEmpty from 'lodash/isEmpty'
import map from 'lodash/map'
@ -15,6 +15,8 @@ import { T9n } from 'features/T9n'
import { PlayButton } from '../PlayButton'
export const LIST_INDENT = 30
type Props = {
live?: boolean,
onSelect?: (selectedMathPlaylist: PlaylistOption) => void,
@ -22,38 +24,38 @@ type Props = {
selectedMathPlaylist?: PlaylistOption,
}
const List = styled.ul``
const List = styled.ul`
margin-bottom: ${LIST_INDENT}px;
`
const Item = styled.li`
margin-bottom: 12px;
width: 100%;
height: 36px;
${isMobileDevice
? css`
height: 32px;
margin-bottom: 9px;
`
: ''};
`
export const MatchPlaylists = ({
live,
onSelect,
playlists,
selectedMathPlaylist,
}: Props) => {
const filteredPlayListByDuration = useMemo(() => (
filter(playlists, (playlist) => (
live
? Boolean(playlist.duration) || (playlist.id === 'full_game')
: Boolean(playlist.duration)
))
), [playlists, live])
:last-child {
margin-bottom: 0;
}
`
return (
<List>
export const MatchPlaylists = forwardRef(
({
live,
onSelect,
playlists,
selectedMathPlaylist,
}: Props,
ref: ForwardedRef<HTMLUListElement>) => (
<List ref={ref}>
{
map(filteredPlayListByDuration, (playlist) => (
map(playlists, (playlist) => (
<Item key={playlist.id}>
<PlayButton
duration={playlist.duration}
@ -68,5 +70,5 @@ export const MatchPlaylists = ({
))
}
</List>
)
}
),
)

@ -9,6 +9,7 @@ import {
import { format } from 'date-fns'
import map from 'lodash/map'
import sortBy from 'lodash/sortBy'
import isEmpty from 'lodash/isEmpty'
import { MatchCard } from 'features/MatchCard'
import { TournamentData } from 'features/MatchPage/types'
@ -19,50 +20,32 @@ import { parseDate } from 'helpers/parseDate'
import { usePageParams } from 'hooks/usePageParams'
import { VideoDate } from './components/VideoDate'
import { MatchesWrapper } from './styled'
import { MatchesWrapper, Title } from './styled'
type Props = {
additionalScrollHeight: number,
profile: MatchInfo,
tournamentData: TournamentData,
}
const formatDate = (date: Date) => format(date, 'yyyy-MM-dd')
export const TabVideo = ({
export const Matches = ({
additionalScrollHeight,
profile,
tournamentData,
}: Props) => {
const { profileId } = usePageParams()
const profileDate = useMemo(() => (
format(parseDate(profile?.date!), 'yyyy-MM-dd')
formatDate(parseDate(profile?.date!))
), [profile?.date])
const [selectedDate, setSelectedDate] = useState(profileDate)
const matches = useMemo(() => (
tournamentData.matches.filter((match) => (
formatDate(match.date) === selectedDate && match.id !== profileId
formatDate(match.date) === profileDate && match.id !== profileId
))
), [profileId, selectedDate, tournamentData.matches])
const isInitialDateHidden = useMemo(() => {
const someProfileMatch = tournamentData.matches.find((match) => (
formatDate(match.date) === profileDate && match.id !== profileId))
if (!someProfileMatch) {
const profileDateIndex = tournamentData.matchDates.findIndex((date) => date === profileDate)
if (profileDateIndex !== 0) {
setSelectedDate(tournamentData.matchDates[profileDateIndex - 1])
} else {
setSelectedDate(tournamentData.matchDates[profileDateIndex + 1])
}
}
return !someProfileMatch
}, [tournamentData, profileId, profileDate])
), [profileDate, profileId, tournamentData.matches])
const ref = useRef<HTMLDivElement | null>(null)
const [overflow, setOverflow] = useState(false)
@ -75,18 +58,18 @@ export const TabVideo = ({
const hasScroll = scrollHeight > clientHeight
setOverflow(hasScroll)
}, [ref, selectedDate])
}, [ref.current?.clientHeight])
if (isEmpty(matches)) return null
return (
<Fragment>
<VideoDate
isInitialDateHidden={isInitialDateHidden}
matchDates={tournamentData.matchDates}
selectedDate={selectedDate}
profileDate={profileDate}
onDateClick={setSelectedDate}
/>
<MatchesWrapper ref={ref} hasScroll={overflow}>
<Title t='other_games' />
<MatchesWrapper
ref={ref}
hasScroll={overflow}
additionalScrollHeight={additionalScrollHeight}
>
{
map(sortBy(matches, ({ live }) => !live), (match) => (
<MatchCard

@ -0,0 +1,40 @@
import styled, { css } from 'styled-components/macro'
import { customScrollbar } from 'features/Common'
import { T9n } from 'features/T9n'
import { isMobileDevice } from '../../../../config/userAgent'
type MatchesWrapperProps = {
additionalScrollHeight: number,
hasScroll?: boolean,
}
export const MatchesWrapper = styled.div<MatchesWrapperProps>`
overflow-y: auto;
max-height: calc(100vh - 200px - ${({ additionalScrollHeight }) => additionalScrollHeight}px);
padding-right: ${({ hasScroll }) => (hasScroll ? '10px' : '')};
> * {
:not(:last-child) {
margin-bottom: 10px;
}
}
${customScrollbar}
${isMobileDevice ? css`
overflow: hidden;
max-height: initial;
` : ''}
`
export const Title = styled(T9n)`
display: flex;
justify-content: center;
margin-bottom: 15px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.5);
`

@ -13,6 +13,7 @@ import { Button, Title } from '../../styled'
type Props = {
active?: boolean,
children: ReactNode,
className?: string,
disabled?: boolean,
duration?: number,
leftContent?: ReactNode,
@ -24,7 +25,7 @@ type TLiveBtn = {
isActive?: boolean,
}
const Duration = styled(Title)`
export const Duration = styled(Title)`
margin-left: 10px;
font-weight: normal;
`
@ -40,6 +41,7 @@ export const LiveBtn = styled(LiveBtnBase)<TLiveBtn>`
export const PlayButton = ({
active,
children,
className,
disabled,
duration,
leftContent,
@ -47,6 +49,7 @@ export const PlayButton = ({
onClick,
}: Props) => (
<Button
className={className}
onClick={onClick}
active={active}
disabled={disabled}

@ -15,12 +15,9 @@ import type {
} from 'features/MatchPage/types'
import { Name } from 'features/Name'
import { T9n } from 'features/T9n'
import { isEqual } from '../../helpers'
import { PlayButton } from '../PlayButton'
import { BlockTitle } from '../../styled'
import {
Wrapper,
List,
@ -28,6 +25,8 @@ import {
PlayerAvatar,
Tabs,
Tab,
PlayerNum,
PlayButton,
} from './styled'
enum Teams {
@ -58,9 +57,6 @@ export const PlayersPlaylists = ({
return (
<Wrapper>
<BlockTitle>
<T9n t='players_episodes' />
</BlockTitle>
<Tabs>
<Tab
active={selectedTeam === Teams.TEAM1}
@ -91,6 +87,7 @@ export const PlayersPlaylists = ({
/>
)}
>
<PlayerNum>{player.num}</PlayerNum>
<Name nameObj={player} />
</PlayButton>
</PlayersItem>

@ -2,11 +2,13 @@ import styled, { css } from 'styled-components/macro'
import { ProfileLogo } from 'features/ProfileLogo'
import { PlayButton as PlayButtonBase, Duration } from '../PlayButton'
import { Title } from '../../styled'
export const Wrapper = styled.div`
display: flex;
flex-direction: column;
align-items: center;
margin-top: 25px;
`
export const List = styled.ul`
@ -20,18 +22,14 @@ export const PlayersItem = styled.li`
`
export const PlayerAvatar = styled(ProfileLogo)`
position: absolute;
left: 5px;
bottom: 0;
width: 28px;
height: 34px;
object-fit: cover;
`
export const Tabs = styled.div`
display: flex;
margin-top: 4px;
margin-bottom: 8px;
margin-top: -10px;
margin-bottom: 6px;
`
type TabProps = {
@ -70,3 +68,24 @@ export const Tab = styled.button<TabProps>`
margin-right: 48px;
}
`
export const PlayerNum = styled.span`
display: inline-block;
width: 37px;
font-size: 14px;
font-weight: initial;
color: ${({ theme }) => theme.colors.white};
`
export const PlayButton = styled(PlayButtonBase)`
justify-content: initial;
padding-right: 10px;
${Duration} {
margin-left: auto;
}
${Title} {
max-width: 75%;
}
`

@ -0,0 +1,87 @@
import type { PropsWithChildren, HTMLProps } from 'react'
import { memo, useRef } from 'react'
import { createPortal } from 'react-dom'
import { isMobileDevice, KEYBOARD_KEYS } from 'config'
import {
useEventListener,
useModalRoot,
useTooltip,
} from 'hooks'
import { Tooltip } from '../TabStats/styled'
import { CellContainer } from './styled'
type CellProps = {
anchorId?: string,
as?: 'td' | 'th',
clickable?: boolean,
columnWidth?: number,
hasValue?: boolean,
sorted?: boolean,
tooltipText?: string,
} & HTMLProps<HTMLTableCellElement>
const CellFC = ({
anchorId,
as,
children,
clickable,
columnWidth,
hasValue,
onClick,
sorted,
tooltipText,
}: PropsWithChildren<CellProps>) => {
const cellRef = useRef<HTMLTableCellElement | null>(null)
const {
isTooltipShown,
onMouseLeave,
onMouseOver,
tooltipStyle,
} = useTooltip()
const modalRoot = useModalRoot()
useEventListener({
callback: (e) => {
if (e.key !== KEYBOARD_KEYS.Enter) return
// @ts-expect-error
onClick()
},
event: 'keydown',
target: cellRef,
})
return (
<CellContainer
ref={cellRef}
as={as}
onClick={onClick}
clickable={clickable}
columnWidth={columnWidth}
sorted={sorted}
hasValue={hasValue}
onMouseOver={tooltipText && !isMobileDevice ? onMouseOver({
anchorId,
horizontalPosition: 'right',
tooltipText,
verticalPosition: 'top',
}) : undefined}
onMouseLeave={tooltipText && !isMobileDevice ? onMouseLeave : undefined}
>
{children}
{isTooltipShown && modalRoot.current && createPortal(
<Tooltip style={tooltipStyle}>
{tooltipText}
</Tooltip>,
modalRoot.current,
)}
</CellContainer>
)
}
export const Cell = memo(CellFC)

@ -0,0 +1,7 @@
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 = 5
export const SCROLLBAR_WIDTH = 8

@ -0,0 +1,73 @@
import { useEffect, useState } from 'react'
import { useMatchPageStore } from 'features/MatchPage/store'
import type { SortCondition, PlayersTableProps } from '../types'
import { usePlayers } from './usePlayers'
import { useTable } from './useTable'
export const usePlayersTable = ({ teamId }: PlayersTableProps) => {
const [sortCondition, setSortCondition] = useState<SortCondition>({
clicksCount: 0,
dir: 'asc',
paramId: null,
})
const { plaingOrder, setCircleAnimation } = useMatchPageStore()
const {
getPlayerName,
getPlayerParams,
players,
} = usePlayers({ sortCondition, teamId })
const {
containerRef,
getDisplayedValue,
handleParamClick,
handleScroll,
handleSortClick,
isExpanded,
paramColumnWidth,
params,
showExpandButton,
showLeftArrow,
showRightArrow,
slideLeft,
slideRight,
tableWrapperRef,
toggleIsExpanded,
} = useTable({
setSortCondition,
teamId,
})
useEffect(() => {
setCircleAnimation((state) => ({
...state,
plaingOrder,
}))
}, [setCircleAnimation, plaingOrder])
return {
containerRef,
getDisplayedValue,
getPlayerName,
getPlayerParams,
handleParamClick,
handleScroll,
handleSortClick,
isExpanded,
paramColumnWidth,
params,
players,
showExpandButton,
showLeftArrow,
showRightArrow,
slideLeft,
slideRight,
sortCondition,
tableWrapperRef,
toggleIsExpanded,
}
}

@ -0,0 +1,79 @@
import { useMemo, useCallback } from 'react'
import orderBy from 'lodash/orderBy'
import isNil from 'lodash/isNil'
import trim from 'lodash/trim'
import type { Player } from 'requests'
import { useToggle } from 'hooks'
import { useMatchPageStore } from 'features/MatchPage/store'
import { useLexicsStore } from 'features/LexicsStore'
import type { SortCondition } from '../types'
type UsePlayersArgs = {
sortCondition: SortCondition,
teamId: number,
}
export const usePlayers = ({ sortCondition, teamId }: UsePlayersArgs) => {
const { isOpen: isExpanded, toggle: toggleIsExpanded } = useToggle()
const {
playersData,
playersStats,
profile: matchProfile,
} = useMatchPageStore()
const { suffix } = useLexicsStore()
const getPlayerParams = useCallback(
(playerId: number) => playersStats[teamId][playerId] || {},
[playersStats, teamId],
)
const getPlayerName = useCallback((player: Player) => (
trim(player[`lastname_${suffix}`] || '')
), [suffix])
const getParamValue = useCallback((playerId: number, paramId: number) => {
const playerParams = getPlayerParams(playerId)
const { val } = playerParams[paramId] || {}
return val
}, [getPlayerParams])
const sortedPlayers = useMemo(() => {
const players = playersData[matchProfile?.team1.id === teamId ? 'team1' : 'team2']
return isNil(sortCondition.paramId)
? orderBy(players, 'ord')
: orderBy(
players,
[
(player) => {
const paramValue = getParamValue(player.id, sortCondition.paramId!)
return isNil(paramValue) ? -1 : paramValue
},
'ord',
],
sortCondition.dir,
)
}, [
getParamValue,
playersData,
matchProfile?.team1.id,
sortCondition.dir,
sortCondition.paramId,
teamId,
])
return {
getPlayerName,
getPlayerParams,
isExpanded,
players: sortedPlayers,
toggleIsExpanded,
}
}

@ -0,0 +1,262 @@
import type {
SyntheticEvent,
Dispatch,
SetStateAction,
} from 'react'
import {
useCallback,
useRef,
useState,
useEffect,
useLayoutEffect,
useMemo,
} from 'react'
import { useQueryClient } from 'react-query'
import size from 'lodash/size'
import isNil from 'lodash/isNil'
import reduce from 'lodash/reduce'
import forEach from 'lodash/forEach'
import values from 'lodash/values'
import map from 'lodash/map'
import { isMobileDevice, querieKeys } from 'config'
import type {
PlayerParam,
PlayersStats,
MatchScore,
} from 'requests'
import { getStatsEvents } from 'requests'
import { usePageParams, useToggle } from 'hooks'
import { useMatchPageStore } from 'features/MatchPage/store'
import { useLexicsConfig } from 'features/LexicsStore'
import { getHalfTime } from 'features/MatchPage/helpers/getHalfTime'
import type { SortCondition } from '../types'
import {
PARAM_COLUMN_WIDTH_DEFAULT,
FIRST_COLUMN_WIDTH_DEFAULT,
DISPLAYED_PARAMS_COLUMNS,
SCROLLBAR_WIDTH,
} from '../config'
import { StatsType } from '../../TabStats/config'
type UseTableArgs = {
setSortCondition: Dispatch<SetStateAction<SortCondition>>,
teamId: number,
}
type HeaderParam = Pick<PlayerParam, 'id' | 'lexica_short' | 'lexic'>
export const useTable = ({
setSortCondition,
teamId,
}: UseTableArgs) => {
const containerRef = useRef<HTMLDivElement>(null)
const tableWrapperRef = useRef<HTMLDivElement>(null)
const [showLeftArrow, setShowLeftArrow] = useState(false)
const [showRightArrow, setShowRightArrow] = useState(true)
const [paramColumnWidth, setParamColumnWidth] = useState(PARAM_COLUMN_WIDTH_DEFAULT)
const {
close: reduceTable,
isOpen: isExpanded,
toggle: toggleIsExpanded,
} = useToggle()
const {
playersStats,
playingProgress,
playStatsEpisodes,
profile,
setIsPlayingFiltersEpisodes,
setPlayingData,
setWatchAllEpisodesTimer,
statsType,
} = useMatchPageStore()
const { profileId, sportType } = usePageParams()
const client = useQueryClient()
const matchScore = client.getQueryData<MatchScore>(querieKeys.matchScore)
const params = useMemo(() => (
reduce<PlayersStats, Record<string, HeaderParam>>(
playersStats[teamId],
(acc, curr) => {
forEach(values(curr), ({
id,
lexic,
lexica_short,
}) => {
acc[id] = acc[id] || {
id,
lexic,
lexica_short,
}
})
return acc
},
{},
)
), [playersStats, teamId])
const lexics = useMemo(() => (
reduce<HeaderParam, Array<number>>(
values(params),
(acc, { lexic, lexica_short }) => {
if (lexic) acc.push(lexic)
if (lexica_short) acc.push(lexica_short)
return acc
},
[],
)
// eslint-disable-next-line react-hooks/exhaustive-deps
), [map(params, 'id').sort().join('')])
useLexicsConfig(lexics)
const paramsCount = size(params)
const getParamColumnWidth = useCallback(() => {
const paramsTableWidth = (
(containerRef.current?.clientWidth || 0)
- FIRST_COLUMN_WIDTH_DEFAULT
- SCROLLBAR_WIDTH - 8
)
return isExpanded
? PARAM_COLUMN_WIDTH_DEFAULT
: paramsTableWidth / DISPLAYED_PARAMS_COLUMNS
}, [isExpanded])
const slideLeft = () => {
const {
clientHeight = 0,
clientWidth = 0,
scrollHeight = 0,
scrollLeft = 0,
scrollWidth = 0,
} = tableWrapperRef.current || {}
const hasVerticalScroll = scrollHeight > clientHeight
const scrollRight = scrollWidth - (scrollLeft + clientWidth)
const scrollBy = scrollRight === 0
? paramColumnWidth - (hasVerticalScroll ? SCROLLBAR_WIDTH : SCROLLBAR_WIDTH * 2)
: paramColumnWidth
tableWrapperRef.current?.scrollBy(-scrollBy, 0)
}
const slideRight = () => {
tableWrapperRef.current?.scrollBy(paramColumnWidth, 0)
}
const getDisplayedValue = ({ val }: PlayerParam) => (isNil(val) ? '-' : String(val))
const handleScroll = (e: SyntheticEvent<HTMLDivElement>) => {
const {
clientWidth,
scrollLeft,
scrollWidth,
} = e.currentTarget
const scrollRight = scrollWidth - (scrollLeft + clientWidth)
setShowLeftArrow(scrollLeft > 0)
setShowRightArrow(scrollRight > 0)
}
const handleSortClick = (paramId: number) => () => {
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,
}
})
}
const handleParamClick = async (paramId: number, playerId: number) => {
setWatchAllEpisodesTimer(false)
setIsPlayingFiltersEpisodes(false)
const videoBounds = matchScore?.video_bounds || profile?.video_bounds
setPlayingData({
player: {
id: playerId,
paramId,
},
team: {
id: null,
paramId: null,
},
})
try {
const events = await getStatsEvents({
matchId: profileId,
paramId,
playerId,
sportType,
teamId,
...(statsType === StatsType.CURRENT_STATS && videoBounds && (
getHalfTime(videoBounds, playingProgress)
)),
})
playStatsEpisodes(events)
// eslint-disable-next-line no-empty
} catch (e) {}
}
useLayoutEffect(() => {
setParamColumnWidth(getParamColumnWidth())
}, [getParamColumnWidth, containerRef.current?.clientWidth])
useLayoutEffect(() => {
const {
clientWidth = 0,
scrollLeft = 0,
scrollWidth = 0,
} = tableWrapperRef.current || {}
const scrollRight = scrollWidth - (scrollLeft + clientWidth)
setShowRightArrow(scrollRight > 0)
}, [isExpanded])
useEffect(() => {
if (isExpanded && paramsCount <= DISPLAYED_PARAMS_COLUMNS) {
reduceTable()
}
}, [isExpanded, paramsCount, reduceTable])
return {
containerRef,
getDisplayedValue,
handleParamClick,
handleScroll,
handleSortClick,
isExpanded,
paramColumnWidth,
params,
showExpandButton: !isMobileDevice && paramsCount > DISPLAYED_PARAMS_COLUMNS,
showLeftArrow,
showRightArrow,
slideLeft,
slideRight,
tableWrapperRef,
toggleIsExpanded,
}
}

@ -0,0 +1,201 @@
import { Fragment } from 'react'
import map from 'lodash/map'
import includes from 'lodash/includes'
import { PlayerParam } from 'requests'
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,
Header,
Row,
PlayerNum,
PlayerName,
ParamShortTitle,
ArrowButtonRight,
ArrowButtonLeft,
Arrow,
ExpandButton,
} from './styled'
import { CircleAnimationBar } from '../CircleAnimationBar'
export const PlayersTable = (props: PlayersTableProps) => {
const {
containerRef,
getDisplayedValue,
getPlayerName,
getPlayerParams,
handleParamClick,
handleScroll,
handleSortClick,
isExpanded,
paramColumnWidth,
params,
players,
showExpandButton,
showLeftArrow,
showRightArrow,
slideLeft,
slideRight,
sortCondition,
tableWrapperRef,
toggleIsExpanded,
} = usePlayersTable(props)
const { translate } = useLexicsStore()
const { sportName } = usePageParams()
const {
isPlayersStatsFetching,
playingData,
watchAllEpisodesTimer,
} = useMatchPageStore()
const firstColumnWidth = isExpanded ? FIRST_COLUMN_WIDTH_EXPANDED : FIRST_COLUMN_WIDTH_DEFAULT
return (
<Container
ref={containerRef}
isExpanded={isExpanded}
>
{isPlayersStatsFetching
? <Loader color={defaultTheme.colors.white} />
: (
<TableWrapper
ref={tableWrapperRef}
isExpanded={isExpanded}
onScroll={handleScroll}
>
{!isExpanded && (
<Fragment>
{showRightArrow && (
<ArrowButtonRight
aria-label='Scroll to right'
onClick={slideRight}
>
<Arrow direction='right' />
</ArrowButtonRight>
)}
</Fragment>
)}
<Table role='marquee' aria-live='off'>
<Header>
<Row>
<Cell
as='th'
columnWidth={firstColumnWidth}
>
{showLeftArrow && (
<ArrowButtonLeft
aria-label='Scroll to left'
onClick={slideLeft}
>
<Arrow direction='left' />
</ArrowButtonLeft>
)}
{showExpandButton && (
<ExpandButton
isExpanded={isExpanded}
aria-label={isExpanded ? 'Reduce' : 'Expand'}
onClick={toggleIsExpanded}
>
<Arrow direction={isExpanded ? 'right' : 'left'} />
<Arrow direction={isExpanded ? 'right' : 'left'} />
</ExpandButton>
)}
</Cell>
{map(params, ({
id,
lexic,
lexica_short,
}) => (
<Cell
as='th'
key={id}
columnWidth={paramColumnWidth}
onClick={handleSortClick(id)}
sorted={sortCondition.paramId === id}
tooltipText={translate(lexic)}
anchorId={`param_${id}`}
>
<ParamShortTitle
id={`param_${id}`}
t={lexica_short || ''}
sorted={sortCondition.paramId === id}
sortDirection={sortCondition.dir}
showLeftArrow={showLeftArrow}
/>
</Cell>
))}
</Row>
</Header>
<tbody>
{map(players, (player) => {
const playerName = getPlayerName(player)
const playerNum = player.num ?? player.club_shirt_num
const playerProfileUrl = `/${sportName}/players/${player.id}`
return (
<Row key={player.id}>
<Cell columnWidth={firstColumnWidth}>
<PlayerNum>{playerNum}</PlayerNum>{' '}
<PlayerName to={playerProfileUrl}>
{playerName}
</PlayerName>
</Cell>
{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 (
<Cell
columnWidth={paramColumnWidth}
key={param.id}
clickable={clickable}
sorted={sorted}
onClick={onClick}
hasValue={value !== '-'}
>
{watchAllEpisodesTimer
&& param.id === playingData.player.paramId
&& player.id === playingData.player.id
? (
<CircleAnimationBar
text={value}
size={20}
/>
)
: value}
</Cell>
)
})}
</Row>
)
})}
</tbody>
</Table>
</TableWrapper>
)}
</Container>
)
}

@ -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<ContainerProps>`
--bgColor: #333;
${({ isExpanded }) => (isExpanded
? css`
--bgColor: rgba(51, 51, 51, 0.7);
`
: css`
position: relative;
`)}
`
type TableWrapperProps = {
isExpanded?: boolean,
}
export const TableWrapper = styled.div<TableWrapperProps>`
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)<ParamShortTitleProps>`
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 },
}))<CellContainerProps>`
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)<ExpandButtonProps>`
left: 20px;
top: 0;
${Arrow} {
left: ${({ isExpanded }) => (isExpanded ? -6 : -2)}px;
:last-child {
margin-left: 7px;
}
}
`

@ -0,0 +1,9 @@
export type PlayersTableProps = {
teamId: number,
}
export type SortCondition = {
clicksCount: number,
dir: 'asc' | 'desc',
paramId: number | null,
}

@ -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 = ({
<EpisodesCount>
{size(flatten(reversedGroupEvents))} <T9n t='episodes_selected' />
</EpisodesCount>
<WatchButton onClick={playEpisodes}>
<WatchButton onClick={() => playEpisodes()}>
<T9n t='watch_all' />
</WatchButton>
{watchAllEpisodesTimer && (
<CircleAnimationBar
filteredEvents={filteredEvents}
circleAnimation={circleAnimation}
setWatchAllEpisodesTimer={setWatchAllEpisodesTimer}
/>
<CircleAnimationBar />
)}
</SelectedEpisodes>
)}

@ -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<TTab>`
cursor: pointer;
width: 13px;
height: 13px;
display: inline-block;
:nth-child(2) {
margin-left: 3px;
@ -210,10 +216,6 @@ export const Button = styled(ButtonBase) <ButtonProps>`
background-size: cover;
` : ''
)}
&:hover ${EventDesc} {
overflow: visible;
}
${({ isHomeTeam }) => (
isHomeTeam

@ -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) => (
<PlayersPlaylists
profile={profile}
players={playlists.players}
selectedMathPlaylist={selectedPlaylist}
onSelect={onSelect}
/>
)

@ -0,0 +1,10 @@
export enum Tabs {
TEAMS,
TEAM1,
TEAM2,
}
export enum StatsType {
FINAL_STATS,
CURRENT_STATS,
}

@ -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>(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,
}
}

@ -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<typeof PlayersTable>) => <PlayersTable {...props} />,
// eslint-disable-next-line react/jsx-props-no-spreading
[Tabs.TEAM2]: (props: ComponentProps<typeof PlayersTable>) => <PlayersTable {...props} />,
}
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 (
<Container>
<Header>
<TabList>
{isVisibleTeamsTab && (
<Tab
aria-pressed={selectedTab === Tabs.TEAMS}
onClick={() => setSelectedTab(Tabs.TEAMS)}
>
<TabTitle>
<T9n t='team' />
</TabTitle>
</Tab>
)}
{isVisibleTeam1PlayersTab && (
<Tab
aria-pressed={selectedTab === Tabs.TEAM1}
onClick={() => setSelectedTab(Tabs.TEAM1)}
>
<TabTitle
teamColor={team1.shirt_color}
onMouseOver={isMobileDevice
? undefined
: onMouseOver({
anchorId: 'team1Tab',
horizontalPosition: 'left',
indent: 25,
tooltipText: team1[`name_${suffix}`],
})}
onMouseLeave={isMobileDevice ? undefined : onMouseLeave}
>
<Name
id='team1Tab'
nameObj={{
name_eng: team1.abbrev_eng || getTeamAbbr(team1.name_eng),
name_rus: team1.abbrev_rus || getTeamAbbr(team1.name_rus),
}}
/>
</TabTitle>
</Tab>
)}
{isVisibleTeam2PlayersTab && (
<Tab
aria-pressed={selectedTab === Tabs.TEAM2}
onClick={() => setSelectedTab(Tabs.TEAM2)}
>
<TabTitle
teamColor={team2.shirt_color}
onMouseOver={isMobileDevice
? undefined
: onMouseOver({
anchorId: 'team2Tab',
horizontalPosition: 'left',
indent: 25,
tooltipText: team2[`name_${suffix}`],
})}
onMouseLeave={isMobileDevice ? undefined : onMouseLeave}
>
<Name
id='team2Tab'
nameObj={{
name_eng: team2.abbrev_eng || getTeamAbbr(team2.name_eng),
name_rus: team2.abbrev_rus || getTeamAbbr(team2.name_rus),
}}
/>
</TabTitle>
</Tab>
)}
</TabList>
<Switch>
<SwitchTitle t={switchTitleLexic} />
<SwitchButton
id='switchButton'
isFinalStatsType={isFinalStatsType}
onClick={toggleStatsType}
onMouseOver={isMobileDevice
? undefined
: onMouseOver({
anchorId: 'switchButton',
horizontalPosition: 'right',
tooltipText: translate(switchButtonTooltipLexic),
})}
onMouseLeave={isMobileDevice ? undefined : onMouseLeave}
/>
</Switch>
</Header>
<TabPane
teamId={selectedTab === Tabs.TEAM1 ? team1.id : team2.id}
/>
{isTooltipShown && modalRoot.current && createPortal(
<Tooltip style={tooltipStyle}>
{tooltipText}
</Tooltip>,
modalRoot.current,
)}
</Container>
)
}

@ -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<TabTitleProps>`
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<SwitchButtonProps>`
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%)
`
)}
`

@ -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<string>,
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 (
<Wrapper>
{previousDay && (
<WeekDay
onClick={() => onDayClick(previousDay)}
>{formatDate(previousDay)}
</WeekDay>
)}
{currentDay && (
<WeekDay
isActive
>{formatDate(currentDay)}
</WeekDay>
)}
{nextDay && (
<WeekDay
onClick={() => onDayClick(nextDay)}
>{formatDate(nextDay)}
</WeekDay>
)}
</Wrapper>
)
}

@ -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;
}
`
: '')}
`

@ -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;
` : ''}
`

@ -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) => (
<Fragment>
<MatchPlaylists
playlists={playlists.match}
selectedMathPlaylist={selectedPlaylist}
onSelect={onSelect}
live={profile?.live}
/>
<DropdownSection
itemsCount={size(playlists.interview)}
title={playlists.lexics?.interview}
>
<SideInterviews
interviews={playlists.interview}
tournamentData,
}: Props) => {
const matchPlaylistsRef = useRef<HTMLUListElement>(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 (
<Fragment>
<MatchPlaylists
ref={matchPlaylistsRef}
playlists={filteredPlayListByDuration}
selectedMathPlaylist={selectedPlaylist}
onSelect={onSelect}
live={profile?.live}
/>
</DropdownSection>
{!isEmpty(playlists.players.team1) && (
<PlayersPlaylists
<DropdownSection
itemsCount={size(playlists.interview)}
title={playlists.lexics?.interview}
>
<SideInterviews
interviews={playlists.interview}
selectedMathPlaylist={selectedPlaylist}
onSelect={onSelect}
/>
</DropdownSection>
<Matches
profile={profile}
players={playlists.players}
selectedMathPlaylist={selectedPlaylist}
onSelect={onSelect}
tournamentData={tournamentData}
additionalScrollHeight={additionalScrollHeight}
/>
)}
</Fragment>
)
</Fragment>
)
}

@ -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<MatchScore>(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 (
<CellContainer>
<ParamValueContainer ref={paramValueContainerRef}>
{watchAllEpisodesTimer
&& playingData.team.paramId === teamStatItem.param1.id
&& playingData.team.id === teamId
? (
<ParamValue>
<CircleAnimationBar
text={getDisplayedValue(teamStatItem.param1.val)}
size={20}
/>
</ParamValue>
)
: (
<ParamValue
clickable={isClickable(teamStatItem.param1)}
onClick={() => onParamClick(teamStatItem.param1)}
data-param-id={teamStatItem.param1.id}
hasValue={Boolean(teamStatItem.param1.val)}
>
{getDisplayedValue(teamStatItem.param1.val)}
</ParamValue>
)}
{teamStatItem.param2 && (
<Fragment>
{watchAllEpisodesTimer
&& playingData.team.paramId === teamStatItem.param2.id
&& playingData.team.id === teamId
? (
<ParamValue>
<CircleAnimationBar
text={getDisplayedValue(teamStatItem.param2.val)}
size={20}
/>
</ParamValue>
)
: (
<Fragment>
<Divider>/</Divider>
<ParamValue
clickable={isClickable(teamStatItem.param2)}
onClick={() => onParamClick(teamStatItem.param2!)}
data-param-id={teamStatItem.param2.id}
hasValue={Boolean(teamStatItem.param2.val)}
>
{getDisplayedValue(teamStatItem.param2.val)}
</ParamValue>
</Fragment>
)}
</Fragment>
)}
</ParamValueContainer>
</CellContainer>
)
}

@ -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,
}
}

@ -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 (
<Loader color={defaultTheme.colors.white} />
)
}
return (
<Container>
<TableWrapper>
<Table role='marquee' aria-live='off'>
<Header>
<Row>
<CellContainer as='th'>
<TeamShortName
nameObj={profile.team1}
prefix='abbrev_'
/>
</CellContainer>
<CellContainer as='th' />
<CellContainer as='th'>
<TeamShortName
nameObj={profile.team2}
prefix='abbrev_'
/>
</CellContainer>
</Row>
</Header>
<tbody>
{map(teamsStats[profile.team1.id], (team1StatItem) => {
const team2StatItem = getStatItemById(team1StatItem.param1.id)
const statItemTitle = team1StatItem[`name_${shortSuffix}`]
return (
<Row key={team1StatItem.param1.id}>
<Cell
teamStatItem={team1StatItem}
teamId={profile.team1.id}
/>
<CellContainer>
<StatItemTitle>{statItemTitle}</StatItemTitle>
</CellContainer>
<Cell
teamStatItem={team2StatItem}
teamId={profile.team2.id}
/>
</Row>
)
})}
</tbody>
</Table>
</TableWrapper>
</Container>
)
}

@ -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 },
}))<TParamValue>`
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;
`

@ -1,5 +1,6 @@
export enum Tabs {
WATCH,
EVENTS,
VIDEO
STATS,
PLAYERS,
}

@ -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>(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,

@ -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 (
<Wrapper ref={containerRef}>
<TabsWrapper>
<TabsGroup>
<TabsGroup hasLessThanFourTabs={hasLessThanFourTabs}>
{isWatchTabVisible ? (
<Tab
selected={selectedTab === Tabs.WATCH}
aria-pressed={selectedTab === Tabs.WATCH}
onClick={() => onTabClick(Tabs.WATCH)}
>
<T9n t='watch' />
<TabIcon icon='watch' />
<TabTitle t='watch' />
</Tab>
) : null}
{isEventTabVisible ? (
<Tab
selected={selectedTab === Tabs.EVENTS}
aria-pressed={selectedTab === Tabs.EVENTS}
onClick={() => onTabClick(Tabs.EVENTS)}
>
<T9n t='actions' />
<TabIcon icon='plays' />
<TabTitle t='actions' />
</Tab>
) : null}
{isPlayersTabVisible ? (
<Tab
aria-pressed={selectedTab === Tabs.PLAYERS}
onClick={() => onTabClick(Tabs.PLAYERS)}
>
<TabIcon icon='players' />
<TabTitle t='players' />
</Tab>
) : null}
{isVideoTabVisible ? (
{isStatsTabVisible ? (
<Tab
selected={selectedTab === Tabs.VIDEO}
onClick={() => onTabClick(Tabs.VIDEO)}
aria-pressed={selectedTab === Tabs.STATS}
onClick={() => onTabClick(Tabs.STATS)}
>
<T9n t='video' />
<TabIcon icon='stats' />
<TabTitle t='stats' />
</Tab>
) : null}
</TabsGroup>
@ -130,11 +143,9 @@ export const MatchSidePlaylists = ({
<Container
hasScroll={hasTabPaneScroll}
ref={tabPaneContainerRef}
forVideoTab={selectedTab === Tabs.VIDEO}
forWatchTab={selectedTab === Tabs.WATCH}
>
<TabPane
setCircleAnimation={setCircleAnimation}
circleAnimation={circleAnimation}
tournamentData={tournamentData}
onSelect={onSelect}
playlists={playlists}

@ -1,9 +1,13 @@
import styled, { css } from 'styled-components/macro'
import { devices } from 'config/devices'
import { isMobileDevice } from 'config/userAgent'
import {
isIOS,
isMobileDevice,
devices,
} from 'config'
import { customScrollbar } from 'features/Common'
import { T9n } from 'features/T9n'
export const Wrapper = styled.div`
padding-right: 14px;
@ -12,24 +16,96 @@ export const Wrapper = styled.div`
? css`
overflow-y: auto;
width: 100%;
padding-right: 0;
${customScrollbar}
`
: ''};
`
export const TabsWrapper = styled.div`
padding-left: 14px;
export const TabsWrapper = styled.div``
${isMobileDevice
type TabsGroupProps = {
hasLessThanFourTabs?: boolean,
}
export const TabsGroup = styled.div.attrs({ role: 'tablist' })<TabsGroupProps>`
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<TabIconProps>`
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<TContainer>`
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<TContainer>`
${isMobileDevice
? css`
padding: 0 5px;
padding-bottom: ${isIOS ? 60 : 78}px;
overflow-y: hidden;
max-height: initial;

@ -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<PausedData>,
}
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({

@ -4,6 +4,7 @@ export type Chapter = {
duration: number,
endMs: number,
endOffsetMs: number,
isFullMatchChapter?: boolean,
period: number,
startMs: number,
startOffsetMs: number,

@ -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 <NameStyled className={className}>{name}</NameStyled>
return (
<NameStyled
id={id}
className={className}
>
{name}
</NameStyled>
)
}

@ -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,

@ -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 (
<Text
id={id}
onClick={onClick}
className={className}
>

@ -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) => (
<Fragment>
<TooltipWrapper top={0}>
<TooltipWrapper
role='tooltip'
top={0}
>
<T9n t={lexic} />
</TooltipWrapper>
{children}

@ -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])
}

@ -10,3 +10,4 @@ export * from './getRandomString'
export * from './selectedApi'
export * from './openSubscribePopup'
export * from './getCurrentYear'
export * from './getTeamAbbr'

@ -5,3 +5,5 @@ export * from './useInterval'
export * from './useEventListener'
export * from './useObjectState'
export * from './usePageParams'
export * from './useTooltip'
export * from './useModalRoot'

@ -0,0 +1,5 @@
import { useRef } from 'react'
export const MODAL_ROOT_ID = 'modal-root'
export const useModalRoot = () => useRef(document.getElementById(MODAL_ROOT_ID))

@ -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],
}
}

@ -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<CSSProperties>({})
const [stateAnchorId, setAnchorId] = useState<string | null>(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<HTMLElement>) => {
const target = e.target as HTMLElement
if (anchorId && target.id !== anchorId) return
const {
left,
right,
top,
} = target.getBoundingClientRect()
const coords: Partial<DOMRect> = {
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,
}
}

@ -18,6 +18,7 @@ type Args = {
type PlayerNames = {
name_eng: string,
name_rus: string,
num?: number,
}
export type Event = Episode & {

@ -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<VideoBound>
export type VideoBounds = Array<VideoBound>
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,

@ -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<Player>,
team_id: number,
}
type Response = {
data?: Array<DataItem>,
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 || [])
}

@ -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<Response> => {
export const getMatchScore = ({ profileId, sportType }: Params): Promise<MatchScore> => {
const url = `${API_ROOT}/v1/matches/${sportType}/${profileId}/scores`
const config = {

@ -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<number> | 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 || {})
}

@ -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 || [])
}

@ -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<number>,
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<TeamStatItem>,
},
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 || {})
}

@ -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'

Loading…
Cancel
Save