diff --git a/.drone.yml b/.drone.yml index 6397fe22..7db483ca 100644 --- a/.drone.yml +++ b/.drone.yml @@ -15,9 +15,28 @@ trigger: - refs/heads/master steps: - - name: deploy script + - name: npm-install image: node:16-alpine + environment: + REACT_APP_STRIPE_PK: + from_secret: REACT_APP_STRIPE_PK + commands: + - apk add --no-cache make + - npm install --legacy-peer-deps + + - name: make-prod + image: node:16-alpine + environment: + REACT_APP_STRIPE_PK: + from_secret: REACT_APP_STRIPE_PK + commands: + - apk add --no-cache make + - make prod + depends_on: + - npm-install + - name: deploy-prod + image: amazon/aws-cli:latest environment: AWS_ACCESS_KEY_ID: from_secret: AWS_ACCESS_KEY_ID @@ -25,27 +44,162 @@ steps: from_secret: AWS_SECRET_ACCESS_KEY AWS_DEFAULT_REGION: from_secret: AWS_DEFAULT_REGION - REACT_APP_STRIPE_PK: - from_secret: REACT_APP_STRIPE_PK - SSH_KEY_AUTH: - from_secret: SSH_KEY_AUTH - + AWS_MAX_ATTEMPTS: 10 commands: - - apk add --no-cache aws-cli bash git openssh-client make rsync - - npm install --legacy-peer-deps - - make prod # - aws s3 sync build s3://insports-prod --delete # - aws cloudfront create-invalidation --distribution-id E3KY6BCU3AYHEW --paths "/*" - aws s3 sync build s3://instat-frontend-test-a --delete - aws cloudfront create-invalidation --distribution-id E1WZHVCHZ48SG6 --paths "/*" + depends_on: + - make-prod + + - name: make-auth + image: node:16-alpine + environment: + REACT_APP_STRIPE_PK: + from_secret: REACT_APP_STRIPE_PK + commands: + - apk add --no-cache make - make auth-production-build + depends_on: + - npm-install + + - name: deploy-S3-auth + image: amazon/aws-cli:latest + environment: + AWS_ACCESS_KEY_ID: + from_secret: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: + from_secret: AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION: + from_secret: AWS_DEFAULT_REGION + AWS_MAX_ATTEMPTS: 10 + commands: + - aws s3 sync build s3://insports-auth --delete + - aws cloudfront create-invalidation --distribution-id EERIKX9X2SRPJ --paths "/*" + depends_on: + - make-auth + + - name: deploy-old-auth-server + image: node:16-alpine + environment: + SSH_KEY_AUTH: + from_secret: SSH_KEY_AUTH + commands: + - apk add --no-cache openssh-client rsync - eval $(ssh-agent -s) - echo -n "$SSH_KEY_AUTH" | tr -d '\r' | ssh-add - - mkdir -p ~/.ssh && chmod 700 ~/.ssh - - ssh-keyscan auth.insports.tv >> ~/.ssh/known_hosts - - rsync -v -r -C build_auth/ ubuntu@auth.insports.tv:/home/ubuntu/ott-auth/src/frontend/ - - rsync -v -r -C build_auth/clients/* ubuntu@auth.insports.tv:/home/ubuntu/ott-auth/src/frontend/templates - - ssh ubuntu@auth.insports.tv 'bash -s' < ./run.sh OTT-2535 docker-compose-stage.yaml + depends_on: + - make-auth + + - name: make-india + image: node:16-alpine + environment: + REACT_APP_STRIPE_PK: + from_secret: REACT_APP_STRIPE_PK + commands: + - apk add --no-cache make + - make india-prod + depends_on: + - npm-install + + - name: deploy-india + image: amazon/aws-cli:latest + environment: + AWS_ACCESS_KEY_ID: + from_secret: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: + from_secret: AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION: + from_secret: AWS_DEFAULT_REGION + AWS_MAX_ATTEMPTS: 10 + commands: + - aws s3 sync build_india s3://insports-india --delete + - aws cloudfront create-invalidation --distribution-id E5DKN8IPOMASO --paths "/*" + depends_on: + - make-india + + - name: make-facr + image: node:16-alpine + environment: + REACT_APP_STRIPE_PK: + from_secret: REACT_APP_STRIPE_PK + commands: + - apk add --no-cache make + - make facr-prod + depends_on: + - npm-install + + - name: deploy-facr + image: amazon/aws-cli:latest + environment: + AWS_ACCESS_KEY_ID: + from_secret: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: + from_secret: AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION: + from_secret: AWS_DEFAULT_REGION + AWS_MAX_ATTEMPTS: 10 + commands: + - aws s3 sync build_facr s3://insports-facr-tv --delete + - aws cloudfront create-invalidation --distribution-id E1ZYJS9RAJO89D --paths "/*" + depends_on: + - make-facr + + - name: make-lff + image: node:16-alpine + environment: + REACT_APP_STRIPE_PK: + from_secret: REACT_APP_STRIPE_PK + commands: + - apk add --no-cache make + - make lff-prod + depends_on: + - npm-install + + - name: deploy-lff + image: amazon/aws-cli:latest + environment: + AWS_ACCESS_KEY_ID: + from_secret: AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY: + from_secret: AWS_SECRET_ACCESS_KEY + AWS_DEFAULT_REGION: + from_secret: AWS_DEFAULT_REGION + AWS_MAX_ATTEMPTS: 10 + commands: + - aws s3 sync build_lff s3://insports-tv-lff-lv --delete + - aws cloudfront create-invalidation --distribution-id E2127IDW4TEH4S --paths "/*" + depends_on: + - make-lff + +# - name: make-diwansport +# image: node:16-alpine +# environment: +# REACT_APP_STRIPE_PK: +# from_secret: REACT_APP_STRIPE_PK +# commands: +# - apk add --no-cache make +# - make diwansport-prod +# depends_on: +# - npm-install +# +# - name: deploy-diwansport +# image: amazon/aws-cli:latest +# environment: +# AWS_ACCESS_KEY_ID: +# from_secret: AWS_ACCESS_KEY_ID +# AWS_SECRET_ACCESS_KEY: +# from_secret: AWS_SECRET_ACCESS_KEY +# AWS_DEFAULT_REGION: +# from_secret: AWS_DEFAULT_REGION +# AWS_MAX_ATTEMPTS: 10 +# commands: +# - aws s3 sync build_insports-diwansport s3://insports-diwansport --delete +# - aws cloudfront create-invalidation --distribution-id E3LKAH6TR4O2JL --paths "/*" +# depends_on: +# - make-diwansport --- kind: pipeline @@ -131,7 +285,6 @@ steps: ## - ssh-keyscan auth.insports.tv >> ~/.ssh/known_hosts ## - rsync -v -r -C build_auth/ ubuntu@auth.insports.tv:/home/ubuntu/ott-auth/src/frontend/ ## - rsync -v -r -C build_auth/clients/* ubuntu@auth.insports.tv:/home/ubuntu/ott-auth/src/frontend/templates -## - ssh ubuntu@auth.insports.tv 'bash -s' < ./run.sh OTT-2535 docker-compose-stage.yaml --- @@ -523,17 +676,18 @@ steps: from_secret: AWS_DEFAULT_REGION REACT_APP_STRIPE_PK: from_secret: REACT_APP_STRIPE_PK_TEST - SSH_KEY_AUTH: - from_secret: SSH_KEY_AUTH + SSH_KEY_AUTH_TEST: + from_secret: SSH_KEY_AUTH_TEST commands: - apk add --no-cache aws-cli bash git openssh-client make rsync - npm install --legacy-peer-deps - make auth-build - eval $(ssh-agent -s) - - echo -n "$SSH_KEY_AUTH" | tr -d '\r' | ssh-add - + - echo -n "$SSH_KEY_AUTH_TEST" | tr -d '\r' | ssh-add - - mkdir -p ~/.ssh && chmod 700 ~/.ssh - ssh-keyscan auth.test.insports.tv >> ~/.ssh/known_hosts - rsync -v -r -C build_auth/ ubuntu@auth.test.insports.tv:/home/ubuntu/ott-auth/src/frontend/ - rsync -v -r -C build_auth/clients/* ubuntu@auth.test.insports.tv:/home/ubuntu/ott-auth/src/frontend/templates - - ssh ubuntu@auth.test.insports.tv 'bash -s' < ./run.sh OTT-2535-test docker-compose-test.yaml + - aws s3 sync build_auth s3://auth-insports-test --delete + - aws cloudfront create-invalidation --distribution-id E10YI3RFOZZDLZ --paths "/*" diff --git a/.gitignore b/.gitignore index 932519ba..f8d1d08e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,9 @@ # production /build /build_auth +/build_india +/build_facr +/build_lff # misc .DS_Store diff --git a/Makefile b/Makefile index 293df69c..bd0669a8 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,7 @@ build-stage: clean REACT_APP_TYPE=ott \ REACT_APP_ENV=preproduction \ REACT_APP_CLIENT=insports \ - REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW7v2lF8GGogrm7XaaICZ9CN876sITIBBauZgB2ommUTUOiY868jzpbhQjZcoBOjIRX5Vs54Aq00y4C3USyB \ + REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW5XxhC6ntKZKddXgKHq5HOCDmJTdfSKluMYCdLHOcUA3Miuy8HesxG1eS4c0dQRQpMsEHRrQL00USpu5xIq \ npm run build build-a: clean @@ -120,15 +120,21 @@ auth-production-build: facr-build: clean REACT_APP_TYPE=ott \ - REACT_APP_ENV=staging \ - REACT_APP_CLIENT=facr \ - npm run build + REACT_APP_ENV=staging \ + REACT_APP_CLIENT=facr \ + npm run build + +india-build: clean + REACT_APP_TYPE=ott \ + REACT_APP_ENV=staging \ + REACT_APP_CLIENT=india \ + npm run build lff-build: clean REACT_APP_TYPE=ott \ - REACT_APP_ENV=staging \ + REACT_APP_ENV=staging \ REACT_APP_CLIENT=lff \ - npm run build + npm run build .PHONY: build @@ -136,75 +142,46 @@ prod: clean REACT_APP_TYPE=ott \ REACT_APP_ENV=production \ REACT_APP_CLIENT=insports \ - REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW7v2lF8GGogrm7XaaICZ9CN876sITIBBauZgB2ommUTUOiY868jzpbhQjZcoBOjIRX5Vs54Aq00y4C3USyB \ + REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW5XxhC6ntKZKddXgKHq5HOCDmJTdfSKluMYCdLHOcUA3Miuy8HesxG1eS4c0dQRQpMsEHRrQL00USpu5xIq \ npm run build && cp -r .well-known build -# rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@de.instat.tv:/usr/local/www/ott/wwwroot/ -# rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@fr.instat.tv:/usr/local/www/ott/wwwroot/ -# rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@137.74.33.74:/usr/local/www/ott/wwwroot/ - preprod: clean REACT_APP_TYPE=ott \ REACT_APP_ENV=preproduction \ REACT_APP_STAGE=test \ REACT_APP_CLIENT=insports \ - REACT_APP_STRIPE_PK=pk_live_ANI76cBhSo69DZUxPmyRVIZW \ + REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW5XxhC6ntKZKddXgKHq5HOCDmJTdfSKluMYCdLHOcUA3Miuy8HesxG1eS4c0dQRQpMsEHRrQL00USpu5xIq \ npm run build -facr-prod: clean +india-prod: + rm -rf build_india && \ + REACT_APP_TYPE=ott \ + REACT_APP_ENV=production \ + REACT_APP_CLIENT=india \ + REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW5XxhC6ntKZKddXgKHq5HOCDmJTdfSKluMYCdLHOcUA3Miuy8HesxG1eS4c0dQRQpMsEHRrQL00USpu5xIq \ + BUILD_PATH=build_india \ + npm run build && cp -r .well-known build_india + +facr-prod: + rm -rf build_facr && \ REACT_APP_TYPE=ott \ REACT_APP_ENV=production \ - REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW7v2lF8GGogrm7XaaICZ9CN876sITIBBauZgB2ommUTUOiY868jzpbhQjZcoBOjIRX5Vs54Aq00y4C3USyB \ + REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW5XxhC6ntKZKddXgKHq5HOCDmJTdfSKluMYCdLHOcUA3Miuy8HesxG1eS4c0dQRQpMsEHRrQL00USpu5xIq \ REACT_APP_CLIENT=facr \ - npm run build - - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@de.instat.tv:/usr/local/www/ott/facr-wwwroot/ - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@fr.instat.tv:/usr/local/www/ott/facr-wwwroot/ - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@137.74.33.74:/usr/local/www/ott/facr-wwwroot/ + BUILD_PATH=build_facr \ + npm run build && cp -r .well-known build_facr -lff-prod: clean +lff-prod: + rm -rf build_lff && \ REACT_APP_TYPE=ott \ REACT_APP_ENV=production \ - REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW7v2lF8GGogrm7XaaICZ9CN876sITIBBauZgB2ommUTUOiY868jzpbhQjZcoBOjIRX5Vs54Aq00y4C3USyB \ + REACT_APP_STRIPE_PK=pk_live_51J5TEYEDSxVnTgDW5XxhC6ntKZKddXgKHq5HOCDmJTdfSKluMYCdLHOcUA3Miuy8HesxG1eS4c0dQRQpMsEHRrQL00USpu5xIq \ REACT_APP_CLIENT=lff \ - npm run build - - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@de.instat.tv:/usr/local/www/ott/lff-wwwroot/ - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@fr.instat.tv:/usr/local/www/ott/lff-wwwroot/ - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott@137.74.33.74:/usr/local/www/ott/lff-wwwroot/ + BUILD_PATH=build_lff \ + npm run build && cp -r .well-known build_lff deploy-all: prod preprod facr-prod lff-prod -stage: build-stage - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/wwwroot/ - -a-stage: build-a - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/a-wwwroot/ - -b-stage: build-b - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/b-wwwroot/ - -c-stage: build-c - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/c-wwwroot/ - -d-stage: build-d - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/d-wwwroot/ - -e-stage: build-e - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/e-wwwroot/ - -f-stage: build-f - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/f-wwwroot/ - -g-stage: build-g - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/g-wwwroot/ - -h-stage: build-h - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/h-wwwroot/ - -i-stage: build-i - rsync -zavP --delete-before build/ -e 'ssh -p 666' ott-staging@137.74.33.74:/usr/local/www/ott-staging/i-wwwroot/ - test: npm test diff --git a/package.json b/package.json index 2bcac2b3..9eba47e8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "test:auth": "REACT_APP_TYPE=auth-service react-scripts test", "facr": "REACT_APP_CLIENT=facr react-scripts start", "lff": "REACT_APP_CLIENT=lff react-scripts start", + "india": "REACT_APP_CLIENT=india react-scripts start", + "tunis": "REACT_APP_CLIENT=tunis react-scripts start", "insports": "REACT_APP_CLIENT=insports react-scripts start" }, "dependencies": { diff --git a/public/images/matchTabs/bets.svg b/public/images/matchTabs/bets.svg new file mode 100644 index 00000000..d112cddb --- /dev/null +++ b/public/images/matchTabs/bets.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/matchTabs/chat.svg b/public/images/matchTabs/chat.svg new file mode 100644 index 00000000..02d75c4b --- /dev/null +++ b/public/images/matchTabs/chat.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/matchTabs/players.svg b/public/images/matchTabs/players.svg new file mode 100644 index 00000000..ffa6cb02 --- /dev/null +++ b/public/images/matchTabs/players.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/matchTabs/plays.svg b/public/images/matchTabs/plays.svg new file mode 100644 index 00000000..0850a8e7 --- /dev/null +++ b/public/images/matchTabs/plays.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/matchTabs/stats.svg b/public/images/matchTabs/stats.svg new file mode 100644 index 00000000..12afb401 --- /dev/null +++ b/public/images/matchTabs/stats.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/matchTabs/watch.svg b/public/images/matchTabs/watch.svg new file mode 100644 index 00000000..fdb9114f --- /dev/null +++ b/public/images/matchTabs/watch.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/tunis-logo.svg b/public/images/tunis-logo.svg new file mode 100644 index 00000000..9f66058e --- /dev/null +++ b/public/images/tunis-logo.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/images/tunis_auth_logo.svg b/public/images/tunis_auth_logo.svg new file mode 100644 index 00000000..1b26b37c --- /dev/null +++ b/public/images/tunis_auth_logo.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/index.html b/public/index.html index f73b0ba2..f2343432 100644 --- a/public/index.html +++ b/public/index.html @@ -54,14 +54,21 @@ id="ze-snippet" src="https://static.zdassets.com/ekr/snippet.js?key=2f84e9fe-830c-42bf-afa4-32c90d7c5f7b" > - + - + <% } %> diff --git a/run.sh b/run.sh deleted file mode 100644 index 6b88230f..00000000 --- a/run.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/bash - -branch=$1 -composefile=$2 - -cd /home/ubuntu/ott-auth -docker-compose -f $composefile down -docker-compose -f $composefile up -d -echo "[>] Deployment done." diff --git a/src/components/PictureInPicture/PiP.tsx b/src/components/PictureInPicture/PiP.tsx index b56290c9..30f8661e 100644 --- a/src/components/PictureInPicture/PiP.tsx +++ b/src/components/PictureInPicture/PiP.tsx @@ -1,6 +1,7 @@ import { memo, RefObject, + useEffect, } from 'react' import styled from 'styled-components/macro' @@ -15,10 +16,11 @@ const PipWrapper = styled.div` ` type PipProps = { + isPlaying: boolean, videoRef: RefObject, } -export const PiP = memo(({ videoRef }: PipProps) => { +export const PiP = memo(({ isPlaying, videoRef }: PipProps) => { const togglePip = async () => { try { if ( @@ -33,6 +35,20 @@ export const PiP = memo(({ videoRef }: PipProps) => { } } + useEffect(() => { + window.addEventListener('visibilitychange', async () => { + if ( + document.hidden === true + && document.pictureInPictureEnabled + && videoRef.current !== document.pictureInPictureElement + && videoRef.current?.hidden === false + && isPlaying + ) { + await videoRef.current?.requestPictureInPicture() + } + }) + }, [videoRef, isPlaying]) + return ( diff --git a/src/components/SmartBanner/index.tsx b/src/components/SmartBanner/index.tsx index 97f24a58..817341d4 100644 --- a/src/components/SmartBanner/index.tsx +++ b/src/components/SmartBanner/index.tsx @@ -1,6 +1,7 @@ import { Icon } from 'features/Icon' import { add } from 'date-fns' +import { isIOS } from 'config' import { ScBannerWrap, @@ -38,7 +39,9 @@ export const SmartBanner = ({ setIsOpenDownload }: SmartBannerProps) => ( inSports – the Home of Sports Streaming { diff --git a/src/components/SportIcon/SportIcon.tsx b/src/components/SportIcon/SportIcon.tsx index e28d1f7d..4a01f6fc 100644 --- a/src/components/SportIcon/SportIcon.tsx +++ b/src/components/SportIcon/SportIcon.tsx @@ -52,21 +52,23 @@ export const SportIcon = ({ sport, }: Props) => { const sportType = getSportLexic(sport) - const IconSport = sportIcons[sportType].icon + const IconSport = sportIcons[sportType]?.icon return ( - + {IconSport && ( + + )} ) } diff --git a/src/config/clients/index.tsx b/src/config/clients/index.tsx index 72ddd498..f6835d60 100644 --- a/src/config/clients/index.tsx +++ b/src/config/clients/index.tsx @@ -4,6 +4,8 @@ import { facr } from './facr' import { instat } from './instat' import { lff } from './lff' import { insports } from './insports' +import { india } from './india' +import { tunis } from './tunis' export const currentClient = process.env.REACT_APP_CLIENT || 'insports' @@ -11,12 +13,16 @@ export const isLffClient = currentClient === 'lff' export const isInSportsClient = currentClient === 'insports' export const isInstatClient = currentClient === 'instat' export const isFacrClient = currentClient === 'facr' +export const isIndiaClient = currentClient === 'india' +export const isTunisClient = currentClient === 'tunis' const clients = { facr, + india, insports, instat, lff, + tunis, } export const client: ClientConfig = clients[currentClient] diff --git a/src/config/clients/india.tsx b/src/config/clients/india.tsx new file mode 100644 index 00000000..0eac3a13 --- /dev/null +++ b/src/config/clients/india.tsx @@ -0,0 +1,16 @@ +import { + ClientConfig, + ClientIds, + ClientNames, +} from './types' + +import { insports } from './insports' + +export const india: ClientConfig = { + ...insports, + about_the_project: 'https://prsolution.pro', + auth: { + clientId: ClientIds.India, + }, + name: ClientNames.India, +} diff --git a/src/config/clients/insports.tsx b/src/config/clients/insports.tsx index ec1258c0..95bb14cb 100644 --- a/src/config/clients/insports.tsx +++ b/src/config/clients/insports.tsx @@ -15,6 +15,7 @@ export const insports: ClientConfig = { name: ClientNames.Insports, privacyLink: '/privacy-policy-and-statement?client_id=insports-ott-web', showSearch: true, + showSmartBanner: true, styles: { background: 'background-image: url(/images/Checker.png);', logo: 'insports-logo.svg', diff --git a/src/config/clients/tunis.tsx b/src/config/clients/tunis.tsx new file mode 100644 index 00000000..6764be26 --- /dev/null +++ b/src/config/clients/tunis.tsx @@ -0,0 +1,57 @@ +import { css } from 'styled-components/macro' + +import { + ClientConfig, + ClientIds, + ClientNames, +} from './types' + +const randomHash = () => ( + (Math.random() ** Math.random()) * 9999999999999999 +) + +export const tunis: ClientConfig = { + auth: { + clientId: ClientIds.Tunis, + metaDataUrlParams: `?hash=${randomHash()}`, + }, + defaultLanguage: 'fr', + description: 'Live sports streaming platform. All matches playing under the auspices of Czech Republic FA. Access to full matches, various player playlists, and highlights. Free access in the Czech Republic. Available across all devices', + disabledPreferences: false, + name: ClientNames.Tunis, + privacyLink: '/privacy-policy-and-statement', + showSearch: false, + styles: { + background: '', + homePageHeader: css` + background: radial-gradient( + 160.34% 257.27% at -7.45% 162.22%, + #2AB7AA 3.27%, + #02505C 43.69%, #0B2E4D 100%); + `, + logo: 'tunis-logo.svg', + logoHeight: 6.3, + logoLeft: 1.1, + logoTop: 1.74, + logoWidth: 8.25, + matchLogoHeight: 3.4, + matchLogoTopMargin: 0.9, + matchLogoWidth: 4.5, + matchPageMobileHeaderLogo: css` + width: 35px; + height: 25px; + top: 2px; + `, + mobileHeaderLogo: css` + width: 48px; + height: 37px; + `, + userAccountLogo: css` + width: 4.56rem; + height: 3.488rem; + `, + }, + termsLink: '/terms-and-conditions?client_id=facr-ott-web', + title: 'FACR.TV - The home of Czech football streaming', + userAccountLinksDisabled: true, +} diff --git a/src/config/clients/types.tsx b/src/config/clients/types.tsx index 9cdaca4f..77f01e9b 100644 --- a/src/config/clients/types.tsx +++ b/src/config/clients/types.tsx @@ -6,19 +6,24 @@ type StyledCss = ReturnType export enum ClientIds { Facr = 'facr-ott-web', + India = 'india-ott-web', Insports = 'insports-ott-web', Instat = 'ott-web', Lff = 'lff-ott-web', + Tunis = 'tunis-ott-web', } export enum ClientNames { Facr = 'facr', + India = 'india', Insports = 'insports', Instat = 'instat', Lff = 'lff', + Tunis = 'tunis', } export type ClientConfig = { + about_the_project?: string, auth: { clientId: ClientIds, metaDataUrlParams?: string, @@ -30,6 +35,7 @@ export type ClientConfig = { privacyLink: string, requests?: Record, showSearch?: boolean, + showSmartBanner?: boolean, styles: { background?: string, homePageHeader?: StyledCss, diff --git a/src/config/env.tsx b/src/config/env.tsx index ebe71c86..f0c0b415 100644 --- a/src/config/env.tsx +++ b/src/config/env.tsx @@ -16,7 +16,7 @@ export const isProduction = ENV === 'production' || ENV === 'preproduction' export const stageENV = process.env.REACT_APP_STAGE || 'test' -export const STRIPE_PUBLIC_KEY = process.env.REACT_APP_STRIPE_PK || 'pk_test_51J5TEYEDSxVnTgDWhKLstuDAhx9XmGJmj2awyZ1HghpWdU46MhXqbQt1PyW9XsRlES5JFyuQWbPRjoSsiW3wvXOH00KMirJEGZ' +export const STRIPE_PUBLIC_KEY = process.env.REACT_APP_STRIPE_PK || 'pk_test_51J5TEYEDSxVnTgDWyF63HykCAwKKObIdYCKiCwotte7xvfPw0VhmZiQKzYJIgZ3tCVvQ57JNpGYN7YbxR4JckYUB00HeDWE4YR' export const GOOGLE_CLIENT_ID = process.env.REACT_APP_GOOGLE_CLIENT_ID || '1043133237396-kebgih109kro71b5c7c8qphtgjbd2gdk.apps.googleusercontent.com' export const FACEBOOK_CLIENT_ID = process.env.REACT_APP_FACEBOOK_CLIENT_ID || '798254931203361' diff --git a/src/config/lexics/indexLexics.tsx b/src/config/lexics/indexLexics.tsx index 5d6e1344..545eea28 100644 --- a/src/config/lexics/indexLexics.tsx +++ b/src/config/lexics/indexLexics.tsx @@ -9,8 +9,12 @@ const matchPopupLexics = { apply: 13491, choose_fav_team: 19776, commentators: 15424, + current_stats: 19592, + display_all_stats: 19932, + 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, @@ -22,6 +26,7 @@ const matchPopupLexics = { match_interviews: 13031, match_settings: 13490, no_data: 15397, + others: 19902, players_episodes: 13398, playlist_format: 13406, playlist_format_all_actions: 13408, @@ -31,6 +36,7 @@ const matchPopupLexics = { sec_before: 13411, selected_player_actions: 13413, started_streaming_at: 16042, + stats: 18179, streamed_live_on: 16043, video: 1017, views: 13440, @@ -158,6 +164,7 @@ export const indexLexics = { no_match_access_body: 13419, no_match_access_title: 13418, player: 14975, + players: 164, players_video: 13032, privacy_policy_and_statement: 15404, round_highilights: 13050, diff --git a/src/config/routes.tsx b/src/config/routes.tsx index 8a397573..178b420b 100644 --- a/src/config/routes.tsx +++ b/src/config/routes.tsx @@ -5,20 +5,27 @@ import { ENV, isProduction } from './env' export const APIS = { preproduction: { api: 'https://api.insports.tv', - auth: 'https://api.auth.insports.tv', + auth: 'https://auth.insports.tv', }, production: { api: 'https://api.insports.tv', - auth: 'https://api.auth.insports.tv', + auth: 'https://auth.insports.tv', }, staging: { api: 'https://api.test.insports.tv', - auth: 'https://api.auth.test.insports.tv', + auth: 'https://auth.test.insports.tv', }, } +const VIEWS_APIS = { + preproduction: 'https://views.insports.tv', + production: 'https://views.insports.tv', + staging: 'https://views.test.insports.tv', +} + const env = isProduction ? ENV : readSelectedApi() ?? ENV +export const VIEWS_API = VIEWS_APIS[env] export const AUTH_SERVICE = APIS[env].auth export const API_ROOT = APIS[env].api export const DATA_URL = `${API_ROOT}/data` diff --git a/src/config/userAgent.tsx b/src/config/userAgent.tsx index b0405c07..15dcc250 100644 --- a/src/config/userAgent.tsx +++ b/src/config/userAgent.tsx @@ -1,3 +1,5 @@ export const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) +export const isAndroid = /Android/.test(navigator.userAgent) + export const isMobileDevice = /iPhone|Android/.test(navigator.userAgent) diff --git a/src/features/AuthServiceApp/components/ConfirmPopup/styled.tsx b/src/features/AuthServiceApp/components/ConfirmPopup/styled.tsx index 1123334b..91a89512 100644 --- a/src/features/AuthServiceApp/components/ConfirmPopup/styled.tsx +++ b/src/features/AuthServiceApp/components/ConfirmPopup/styled.tsx @@ -11,6 +11,8 @@ import { HeaderTitle, } from 'features/AuthServiceApp/components/RegisterPopup/styled' +import { client } from '../../config/clients/index' + export const Modal = styled(BaseModal)` ${ModalWindow} { min-height: 220px; @@ -59,6 +61,7 @@ export const ScApplyButton = styled(ApplyButton)` font-size: 14px; ` : ''}; + ${client.styles.popupApplyButton} ` export const ScLink = styled.a` diff --git a/src/features/AuthServiceApp/components/RegisterPopup/index.tsx b/src/features/AuthServiceApp/components/RegisterPopup/index.tsx index 8aef84d2..6d8d78b5 100644 --- a/src/features/AuthServiceApp/components/RegisterPopup/index.tsx +++ b/src/features/AuthServiceApp/components/RegisterPopup/index.tsx @@ -25,11 +25,6 @@ export const RegisterPopup = (props: Props) => { isModalOpen, } = props - // const handleNewConfirmation = () => { - // // TODO дописать логику для отправки доп. письма, может понадобится, когда допишут бэк - // // console.log('send new confirmation') - // } - return ( @@ -53,9 +48,6 @@ export const RegisterPopup = (props: Props) => {
handleModalClose()}>Ok - {/* - - */}
diff --git a/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx b/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx index f8877dd5..a661f487 100644 --- a/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx +++ b/src/features/AuthServiceApp/components/RegisterPopup/styled.tsx @@ -7,7 +7,9 @@ import { ModalWindow } from 'features/Modal/styled' import { Modal as BaseModal } from 'features/Modal' import { Header as BaseHeader } from 'features/PopupComponents' -import { ButtonSolid, ButtonOutline } from 'features/Common' +import { ButtonSolid } from 'features/Common' + +import { client } from '../../config/clients' export const Modal = styled(BaseModal)` background-color: rgba(0, 0, 0, 0.7); @@ -139,26 +141,9 @@ export const ApplyButton = styled(ButtonSolid)` } ` : ''}; + ${client.styles.popupApplyButton} ` -export const SendConfirmationButton = styled(ButtonOutline)` - width: 100%; - height: 50px; - border-radius: 5px; - font-weight: 500; - font-size: 20px; - ${isMobileDevice - ? css` - @media ${devices.mobile}{ - width: 100%; - } - - @media (orientation: landscape){ - width: 290px; - } - ` - : ''}; -` export const Text = styled.span` margin-bottom: 20px; ` diff --git a/src/features/AuthServiceApp/config/clients/index.tsx b/src/features/AuthServiceApp/config/clients/index.tsx index b1401617..6de84332 100644 --- a/src/features/AuthServiceApp/config/clients/index.tsx +++ b/src/features/AuthServiceApp/config/clients/index.tsx @@ -4,12 +4,16 @@ import { facr } from './facr' import { insports } from './insports' import { instat } from './instat' import { lff } from './lff' +import { india } from './india' +import { tunis } from './tunis' const clients = { [ClientIds.Facr]: facr, [ClientIds.Instat]: instat, [ClientIds.Lff]: lff, [ClientIds.Insports]: insports, + [ClientIds.India]: india, + [ClientIds.Tunis]: tunis, } const params = new URLSearchParams(window.location.search) diff --git a/src/features/AuthServiceApp/config/clients/india.tsx b/src/features/AuthServiceApp/config/clients/india.tsx new file mode 100644 index 00000000..e7e1c99e --- /dev/null +++ b/src/features/AuthServiceApp/config/clients/india.tsx @@ -0,0 +1,9 @@ +import { insports as platformInsports } from 'config/clients/insports' + +import type { ClientConfig } from './types' +import { insports } from './insports' + +export const india: ClientConfig = { + ...platformInsports, + ...insports, +} diff --git a/src/features/AuthServiceApp/config/clients/tunis.tsx b/src/features/AuthServiceApp/config/clients/tunis.tsx new file mode 100644 index 00000000..b587b6e8 --- /dev/null +++ b/src/features/AuthServiceApp/config/clients/tunis.tsx @@ -0,0 +1,67 @@ +import styled, { css } from 'styled-components/macro' + +import { tunis as platformTunis } from 'config/clients/tunis' + +import { isMobileDevice } from 'config/userAgent' +import type { ClientConfig } from './types' + +const Background = styled.div` + position: relative; + width: 100%; + height: 100vh; + display: flex; + justify-content: center; + background: linear-gradient(0deg, rgba(2, 46, 48, 0.3), + rgba(2, 46, 48, 0.3)), + radial-gradient(152.89% 271.81% at 0% 96.71%, #2AB7AA 3.27%, #02505C 43.69%, #0B2E4D 100%); +` + +export const tunis: ClientConfig = { + ...platformTunis, + background: Background, + styles: { + centerBlock: css` + margin-top: 9.15rem; + ${isMobileDevice ? css` + margin-top: 107px; + @media screen and (orientation: landscape) { + width: 290px; + margin: auto; + } + ` : ''}; + `, + input: css` + background-color: transparent; + :not(:last-of-type) { + border-color: ${({ theme }) => theme.colors.white}; + } + `, + inputGroup: css` + border: 1px solid ${({ theme }) => theme.colors.white}; + `, + loader: css` + color: #0B2E4D; + `, + logo: css` + background-image: url(/images/tunis_auth_logo.svg); + width: 200px; + height: 178px; + margin-bottom: 1.82rem; + + ${isMobileDevice ? css` + margin-bottom: 20px; + width: 130px; + height: 100px; + ` : ''} + `, + popupApplyButton: css` + background-color: #0E8F84; + color: ${({ theme }) => theme.colors.white}; + `, + popupLoader: '#FFFFFF', + submitButton: css` + background-color: ${({ theme }) => theme.colors.white}; + color: #0B2E4D; + `, + }, +} diff --git a/src/features/AuthServiceApp/config/clients/types.tsx b/src/features/AuthServiceApp/config/clients/types.tsx index 5a305af6..0542b57a 100644 --- a/src/features/AuthServiceApp/config/clients/types.tsx +++ b/src/features/AuthServiceApp/config/clients/types.tsx @@ -12,6 +12,7 @@ export type ClientConfig = { background: FC<{ children: ReactNode }>, defaultLanguage: string, description: string, + isHideSelectLanguages?: boolean, name: ClientNames, privacyLink: string, styles: { diff --git a/src/features/AuthStore/helpers.tsx b/src/features/AuthStore/helpers.tsx index 7f8662a1..d9d1f050 100644 --- a/src/features/AuthStore/helpers.tsx +++ b/src/features/AuthStore/helpers.tsx @@ -23,6 +23,10 @@ export const getClientNameByRedirectUri = () => { switch (client.name) { case ClientNames.Lff: return 'lff.instat' + case ClientNames.India: + return 'india.insports' + case ClientNames.Tunis: + return ClientNames.Tunis case ClientNames.Facr: return ClientNames.Facr case ClientNames.Instat: @@ -35,7 +39,13 @@ export const getClientNameByRedirectUri = () => { const redirectUrl = () => { const clientName = getClientNameByRedirectUri() switch (true) { - case (process.env.NODE_ENV === 'development' || client.name === 'lff' || client.name === 'facr'): + case ( + process.env.NODE_ENV === 'development' + || client.name === 'lff' + || client.name === 'facr' + || client.name === 'india' + || client.name === 'tunis' + ): return `${window.origin}/redirect` case (ENV === 'staging' || ENV === 'preproduction'): return `https://${stageENV}.insports.tv/redirect` diff --git a/src/features/CompanyInfo/index.tsx b/src/features/CompanyInfo/index.tsx index 12c5b4a4..1cc5a30d 100644 --- a/src/features/CompanyInfo/index.tsx +++ b/src/features/CompanyInfo/index.tsx @@ -39,6 +39,8 @@ export const CompanyInfo = ({ ) + case ClientNames.Tunis: + return '' case ClientNames.Lff: return ( diff --git a/src/features/HeaderMobile/index.tsx b/src/features/HeaderMobile/index.tsx index 86d26f4c..2435e6f8 100644 --- a/src/features/HeaderMobile/index.tsx +++ b/src/features/HeaderMobile/index.tsx @@ -1,7 +1,7 @@ import { useRecoilValue } from 'recoil' -import { isIOS } from 'config/userAgent' -import { isLffClient } from 'config/clients' +import { isAndroid, isIOS } from 'config/userAgent' +import { client, isLffClient } from 'config/clients' import { HeaderMenu } from 'features/HeaderMenu' import { DateFilter } from 'features/HeaderFilters' @@ -10,6 +10,8 @@ import { SportsFilter } from 'features/SportsFilter' import { isSportFilterShownAtom } from 'features/HomePage/Atoms/HomePageAtoms' import { SmartBanner } from 'components/SmartBanner' +import { UserInfo } from 'requests' + import { HeaderStyled, ScoreSwitchWrapper, @@ -19,16 +21,22 @@ import { type HeaderBannerProps = { isOpenDownload: boolean, setIsOpenDownload: (open: boolean) => void, + userInfo?: UserInfo, } -export const HeaderMobile = ({ isOpenDownload, setIsOpenDownload }: HeaderBannerProps) => { +export const HeaderMobile = ({ + isOpenDownload, + setIsOpenDownload, + userInfo, +}: HeaderBannerProps) => { const isSportFilterShown = useRecoilValue(isSportFilterShownAtom) return ( <> { isOpenDownload - && !isIOS + && (isAndroid || (isIOS && userInfo?.has_subscription)) + && client.showSmartBanner && } diff --git a/src/features/HomePage/hooks.tsx b/src/features/HomePage/hooks.tsx index c8b11b82..83cc160b 100644 --- a/src/features/HomePage/hooks.tsx +++ b/src/features/HomePage/hooks.tsx @@ -34,7 +34,7 @@ const getTimezoneOffset = (date: Date) => { const getDate = (date: Date) => format(date, 'yyyy-MM-dd') export const useHomePage = () => { - const { user } = useAuthStore() + const { user, userInfo } = useAuthStore() const { selectedDate } = useHeaderFiltersStore() const [isOpenDownload, setIsOpenDownload] = useState(false) const [isShowConfirmPopup, setIsShowConfirmPopup] = useState(false) @@ -54,6 +54,7 @@ export const useHomePage = () => { })() // eslint-disable-next-line react-hooks/exhaustive-deps }, []) + useEffect(() => { const dateLastOpenSmartBanner = localStorage.getItem('dateLastOpenSmartBanner') if (!dateLastOpenSmartBanner @@ -85,5 +86,6 @@ export const useHomePage = () => { isOpenDownload, isShowConfirmPopup, setIsOpenDownload, + userInfo, } } diff --git a/src/features/HomePage/index.tsx b/src/features/HomePage/index.tsx index 6f9fd6ca..e4e476a2 100644 --- a/src/features/HomePage/index.tsx +++ b/src/features/HomePage/index.tsx @@ -29,6 +29,7 @@ const Home = () => { isOpenDownload, isShowConfirmPopup, setIsOpenDownload, + userInfo, } = useHomePage() return ( @@ -37,6 +38,7 @@ const Home = () => { ) : (
diff --git a/src/features/MatchPage/components/FinishedMatch/index.tsx b/src/features/MatchPage/components/FinishedMatch/index.tsx index d10f60d0..d8e22586 100644 --- a/src/features/MatchPage/components/FinishedMatch/index.tsx +++ b/src/features/MatchPage/components/FinishedMatch/index.tsx @@ -17,7 +17,11 @@ import { useMatchPageStore } from '../../store' export const FinishedMatch = () => { const [circleAnimation, setCircleAnimation] = useState(initialCircleAnimation) - const { isOpenPopup, profile } = useMatchPageStore() + const { + isOpenFiltersPopup, + profile, + setPlayingProgress, + } = useMatchPageStore() const { chapters, closeSettingsPopup, @@ -48,9 +52,10 @@ export const FinishedMatch = () => { diff --git a/src/features/MatchPage/components/LiveMatch/hooks/index.tsx b/src/features/MatchPage/components/LiveMatch/hooks/index.tsx index e4240ad0..eab6f4b1 100644 --- a/src/features/MatchPage/components/LiveMatch/hooks/index.tsx +++ b/src/features/MatchPage/components/LiveMatch/hooks/index.tsx @@ -21,6 +21,7 @@ export const useLiveMatch = () => { profile, selectedPlaylist, setFullMatchPlaylistDuration, + setPlayingProgress, } = useMatchPageStore() const { profileId: matchId, sportType } = usePageParams() const resume = useResumeUrlParam() @@ -45,7 +46,7 @@ export const useLiveMatch = () => { } = usePlaylistLogger() const { - onPlayerProgressChange, + onPlayerProgressChange: playerProgressChange, onPlayingChange: notifyProgressLogger, } = usePlayerProgressReporter() @@ -66,6 +67,11 @@ export const useLiveMatch = () => { handlePlaylistClick(playlist, e) } + const onPlayerProgressChange = (seconds: number, period = 0) => { + playerProgressChange(seconds, period) + setPlayingProgress(seconds * 1000) + } + return { chapters, isPlayFilterEpisodes, diff --git a/src/features/MatchPage/index.tsx b/src/features/MatchPage/index.tsx index ed4ec6ad..112a6a4d 100644 --- a/src/features/MatchPage/index.tsx +++ b/src/features/MatchPage/index.tsx @@ -82,6 +82,7 @@ const MatchPageComponent = () => { const sportName = history.location.pathname.split('/')[1] history.push(`/${sportName}/tournaments/${profile.tournament.id}`) } + return ( diff --git a/src/features/MatchPage/store/hooks/index.tsx b/src/features/MatchPage/store/hooks/index.tsx index 8b472265..d0c0b76a 100644 --- a/src/features/MatchPage/store/hooks/index.tsx +++ b/src/features/MatchPage/store/hooks/index.tsx @@ -41,7 +41,7 @@ export const useMatchPage = () => { countOfFilters, filters, isEmptyFilters, - isOpen: isOpenPopup, + isOpen: isOpenFiltersPopup, resetEvents, resetPlayers, toggle: togglePopup, @@ -66,9 +66,16 @@ export const useMatchPage = () => { const { events, handlePlaylistClick, + isEmptyPlayersStats, matchPlaylists, + playersData, + playersStats, selectedPlaylist, setFullMatchPlaylistDuration, + setPlayingProgress, + setStatsType, + statsType, + teamsStats, } = useMatchData(matchProfile) const profile = matchProfile @@ -160,8 +167,9 @@ export const useMatchPage = () => { handlePlaylistClick, hideProfileCard, isEmptyFilters, + isEmptyPlayersStats, isLiveMatch, - isOpenPopup, + isOpenFiltersPopup, isPlayFilterEpisodes, isStarted, likeImage, @@ -170,6 +178,8 @@ export const useMatchPage = () => { plaingOrder, playEpisodes, playNextEpisode, + playersData, + playersStats, profile, profileCardShown, resetEvents, @@ -179,10 +189,14 @@ export const useMatchPage = () => { setFullMatchPlaylistDuration, setIsPlayinFiltersEpisodes, setPlaingOrder, + setPlayingProgress, setReversed, + setStatsType, setUnreversed, setWatchAllEpisodesTimer, showProfileCard, + statsType, + teamsStats, toggleActiveEvents, toggleActivePlayers, togglePopup, diff --git a/src/features/MatchPage/store/hooks/useMatchData.tsx b/src/features/MatchPage/store/hooks/useMatchData.tsx index 4cee0488..39fbf3b5 100644 --- a/src/features/MatchPage/store/hooks/useMatchData.tsx +++ b/src/features/MatchPage/store/hooks/useMatchData.tsx @@ -6,7 +6,7 @@ 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' @@ -16,6 +16,9 @@ import { useMatchPopupStore } from 'features/MatchPopup' import { useMatchPlaylists } from './useMatchPlaylists' import { useEvents } from './useEvents' +import { useTeamsStats } from './useTeamsStats' +import { useStatsTab } from './useStatsTab' +import { usePlayersStats } from './usePlayersStats' const MATCH_DATA_POLL_INTERVAL = 60000 const MATCH_PLAYLISTS_DELAY = 5000 @@ -24,6 +27,7 @@ export const useMatchData = (profile: MatchInfo) => { const { profileId: matchId, sportType } = usePageParams() const { chapters } = useMatchPopupStore() const [matchDuration, setMatchDuration] = useState(0) + const [playingProgress, setPlayingProgress] = useState(0) const { fetchMatchPlaylists, handlePlaylistClick, @@ -33,6 +37,21 @@ export const useMatchData = (profile: MatchInfo) => { setSelectedPlaylist, } = useMatchPlaylists(profile) const { events, fetchMatchEvents } = useEvents() + const { setStatsType, statsType } = useStatsTab() + const { teamsStats } = useTeamsStats({ + matchProfile: profile, + playingProgress, + statsType, + }) + const { + isEmptyPlayersStats, + playersData, + playersStats, + } = usePlayersStats({ + matchProfile: profile, + playingProgress, + statsType, + }) const fetchPlaylistsDebounced = useMemo( () => debounce(fetchMatchPlaylists, MATCH_PLAYLISTS_DELAY), @@ -93,8 +112,15 @@ export const useMatchData = (profile: MatchInfo) => { return { events, handlePlaylistClick, + isEmptyPlayersStats, matchPlaylists, + playersData, + playersStats, selectedPlaylist, setFullMatchPlaylistDuration, + setPlayingProgress, + setStatsType, + statsType, + teamsStats, } } diff --git a/src/features/MatchPage/store/hooks/usePlayersStats.tsx b/src/features/MatchPage/store/hooks/usePlayersStats.tsx new file mode 100644 index 00000000..326d4f51 --- /dev/null +++ b/src/features/MatchPage/store/hooks/usePlayersStats.tsx @@ -0,0 +1,159 @@ +import { + useMemo, + useEffect, + useState, +} from 'react' + +import throttle from 'lodash/throttle' +import isEmpty from 'lodash/isEmpty' +import every from 'lodash/every' +import find from 'lodash/find' + +import type { + MatchInfo, + PlayersStats, + Player, +} from 'requests' +import { getPlayersStats, getMatchParticipants } from 'requests' + +import { useObjectState, usePageParams } from 'hooks' + +import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/config' + +const REQUEST_DELAY = 3000 +const STATS_POLL_INTERVAL = 30000 + +type UsePlayersStatsArgs = { + matchProfile: MatchInfo, + playingProgress: number, + statsType: StatsType, +} + +type PlayersData = { + team1: Array, + team2: Array, +} + +export const usePlayersStats = ({ + matchProfile, + playingProgress, + statsType, +}: UsePlayersStatsArgs) => { + const [playersStats, setPlayersStats] = useObjectState>({}) + const [playersData, setPlayersData] = useState({ team1: [], team2: [] }) + + const { + profileId: matchId, + sportName, + sportType, + } = usePageParams() + + const isCurrentStats = statsType === StatsType.CURRENT_STATS + + const progressSec = Math.floor(playingProgress / 1000) + + const isEmptyPlayersStats = (teamId: number) => ( + isEmpty(playersStats[teamId]) + || every(playersStats[teamId], isEmpty) + || isEmpty(playersData[matchProfile?.team1.id === teamId ? 'team1' : 'team2']) + ) + + const fetchPlayers = useMemo(() => throttle((second?: number) => { + if (!matchProfile?.team1.id || !matchProfile?.team1.id) return + + try { + getMatchParticipants({ + matchId, + second, + sportType, + }).then((data) => { + const team1Players = find(data, { team_id: matchProfile.team1.id })?.players || [] + const team2Players = find(data, { team_id: matchProfile.team2.id })?.players || [] + + setPlayersData({ + team1: team1Players, + team2: team2Players, + }) + }) + + // eslint-disable-next-line no-empty + } catch (e) {} + }, REQUEST_DELAY), [ + matchId, + matchProfile?.team1.id, + matchProfile?.team2.id, + sportType, + ]) + + const fetchPlayersStats = useMemo(() => throttle((second?: number) => { + if (!sportName || !matchProfile?.team1.id || !matchProfile?.team2.id) return + + try { + getPlayersStats({ + matchId, + second, + sportName, + teamId: matchProfile.team1.id, + }).then((data) => setPlayersStats({ [matchProfile.team1.id]: data })) + + getPlayersStats({ + matchId, + second, + sportName, + teamId: matchProfile.team2.id, + }).then((data) => setPlayersStats({ [matchProfile?.team2.id]: data })) + // eslint-disable-next-line no-empty + } catch (e) {} + }, REQUEST_DELAY), [ + matchId, + matchProfile?.team1.id, + matchProfile?.team2.id, + setPlayersStats, + sportName, + ]) + + useEffect(() => { + let interval: NodeJS.Timeout + + fetchPlayers() + + if (!isCurrentStats) { + fetchPlayersStats() + } + + if (matchProfile?.live) { + interval = setInterval(() => { + if (isCurrentStats) return + + fetchPlayersStats() + fetchPlayers() + }, STATS_POLL_INTERVAL) + } + + return () => clearInterval(interval) + }, [ + fetchPlayersStats, + fetchPlayers, + isCurrentStats, + matchProfile?.live, + ]) + + useEffect(() => { + if (isCurrentStats) { + fetchPlayersStats(progressSec) + fetchPlayers(progressSec) + } + }, [ + fetchPlayersStats, + fetchPlayers, + progressSec, + isCurrentStats, + matchProfile?.live, + ]) + + return { + isEmptyPlayersStats, + playersData, + playersStats, + } +} diff --git a/src/features/MatchPage/store/hooks/useStatsTab.tsx b/src/features/MatchPage/store/hooks/useStatsTab.tsx new file mode 100644 index 00000000..9a2a18f1 --- /dev/null +++ b/src/features/MatchPage/store/hooks/useStatsTab.tsx @@ -0,0 +1,12 @@ +import { useState } from 'react' + +import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/config' + +export const useStatsTab = () => { + const [statsType, setStatsType] = useState(StatsType.FINAL_STATS) + + return { + setStatsType, + statsType, + } +} diff --git a/src/features/MatchPage/store/hooks/useTeamsStats.tsx b/src/features/MatchPage/store/hooks/useTeamsStats.tsx new file mode 100644 index 00000000..f9f6ea58 --- /dev/null +++ b/src/features/MatchPage/store/hooks/useTeamsStats.tsx @@ -0,0 +1,78 @@ +import { + useEffect, + useState, + useMemo, +} from 'react' + +import throttle from 'lodash/throttle' + +import type { MatchInfo } from 'requests' +import { getTeamsStats, TeamStatItem } from 'requests' + +import { usePageParams } from 'hooks/usePageParams' + +import { StatsType } from 'features/MatchSidePlaylists/components/TabStats/config' + +const REQUEST_DELAY = 3000 +const STATS_POLL_INTERVAL = 30000 + +type UseTeamsStatsArgs = { + matchProfile: MatchInfo, + playingProgress: number, + statsType: StatsType, +} + +export const useTeamsStats = ({ + matchProfile, + playingProgress, + statsType, +}: UseTeamsStatsArgs) => { + const [teamsStats, setTeamsStats] = useState<{ + [teamId: string]: Array, + }>({}) + + const { profileId: matchId, sportName } = usePageParams() + + const progressSec = Math.floor(playingProgress / 1000) + + const isCurrentStats = statsType === StatsType.CURRENT_STATS + + const fetchTeamsStats = useMemo(() => throttle((second?: number) => { + if (!sportName) return + + getTeamsStats({ + matchId, + second, + sportName, + }).then(setTeamsStats) + }, REQUEST_DELAY), [matchId, sportName]) + + useEffect(() => { + let timer: ReturnType + + if (!isCurrentStats) { + fetchTeamsStats() + } + + if (matchProfile?.live) { + timer = setInterval(() => { + if (isCurrentStats) return + + fetchTeamsStats() + }, STATS_POLL_INTERVAL) + } + + return () => clearInterval(timer) + }, [fetchTeamsStats, matchProfile?.live, isCurrentStats]) + + useEffect(() => { + if (isCurrentStats) { + fetchTeamsStats(progressSec) + } + }, [fetchTeamsStats, progressSec, isCurrentStats]) + + return { + statsType, + teamsStats, + } +} diff --git a/src/features/MatchSidePlaylists/components/MatchPlaylists/index.tsx b/src/features/MatchSidePlaylists/components/MatchPlaylists/index.tsx index e0e49fb9..a0245220 100644 --- a/src/features/MatchSidePlaylists/components/MatchPlaylists/index.tsx +++ b/src/features/MatchSidePlaylists/components/MatchPlaylists/index.tsx @@ -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_ITEM_INDENT = 12 + type Props = { live?: boolean, onSelect?: (selectedMathPlaylist: PlaylistOption) => void, @@ -25,7 +27,7 @@ type Props = { const List = styled.ul`` const Item = styled.li` - margin-bottom: 12px; + margin-bottom: ${LIST_ITEM_INDENT}px; width: 100%; height: 36px; ${isMobileDevice @@ -36,24 +38,17 @@ const Item = styled.li` : ''}; ` -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]) - - return ( - +export const MatchPlaylists = forwardRef( + ({ + live, + onSelect, + playlists, + selectedMathPlaylist, + }: Props, + ref: ForwardedRef) => ( + { - map(filteredPlayListByDuration, (playlist) => ( + map(playlists, (playlist) => ( - ) -} + ), +) diff --git a/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx b/src/features/MatchSidePlaylists/components/Matches/components/VideoDate/index.tsx similarity index 100% rename from src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/index.tsx rename to src/features/MatchSidePlaylists/components/Matches/components/VideoDate/index.tsx diff --git a/src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx b/src/features/MatchSidePlaylists/components/Matches/components/VideoDate/styled.tsx similarity index 100% rename from src/features/MatchSidePlaylists/components/TabVideo/components/VideoDate/styled.tsx rename to src/features/MatchSidePlaylists/components/Matches/components/VideoDate/styled.tsx diff --git a/src/features/MatchSidePlaylists/components/TabVideo/index.tsx b/src/features/MatchSidePlaylists/components/Matches/index.tsx similarity index 88% rename from src/features/MatchSidePlaylists/components/TabVideo/index.tsx rename to src/features/MatchSidePlaylists/components/Matches/index.tsx index 83d165dc..11d9d7de 100644 --- a/src/features/MatchSidePlaylists/components/TabVideo/index.tsx +++ b/src/features/MatchSidePlaylists/components/Matches/index.tsx @@ -23,13 +23,15 @@ import { VideoDate } from './components/VideoDate' import { MatchesWrapper } 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) => { @@ -75,7 +77,9 @@ export const TabVideo = ({ const hasScroll = scrollHeight > clientHeight setOverflow(hasScroll) - }, [ref, selectedDate]) + }, [ref.current?.clientHeight, selectedDate]) + + if (tournamentData.matches.length <= 1) return null return ( @@ -86,7 +90,11 @@ export const TabVideo = ({ profileDate={profileDate} onDateClick={setSelectedDate} /> - + { map(sortBy(matches, ({ live }) => !live), (match) => ( ` +type MatchesWrapperProps = { + additionalScrollHeight: number, + hasScroll?: boolean, +} + +export const MatchesWrapper = styled.div` overflow-y: auto; - max-height: calc(100vh - 170px); + max-height: calc(100vh - 165px - ${({ additionalScrollHeight }) => additionalScrollHeight}px); padding-right: ${({ hasScroll }) => (hasScroll ? '10px' : '')}; > * { diff --git a/src/features/MatchSidePlaylists/components/PlayersPlaylists/index.tsx b/src/features/MatchSidePlaylists/components/PlayersPlaylists/index.tsx index d05ac36e..22b69298 100644 --- a/src/features/MatchSidePlaylists/components/PlayersPlaylists/index.tsx +++ b/src/features/MatchSidePlaylists/components/PlayersPlaylists/index.tsx @@ -15,12 +15,10 @@ 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, @@ -58,9 +56,6 @@ export const PlayersPlaylists = ({ return ( - - - { + const [sortCondition, setSortCondition] = useState({ dir: 'asc', paramId: null }) + + const { + getFullName, + getPlayerParams, + players, + } = usePlayers({ sortCondition, teamId }) + + const { + containerRef, + firstColumnWidth, + getDisplayedValue, + handleScroll, + handleSortClick, + isExpanded, + paramColumnWidth, + params, + showExpandButton, + showLeftArrow, + showRightArrow, + slideLeft, + slideRight, + tableWrapperRef, + toggleIsExpanded, + } = useTable({ + setSortCondition, + teamId, + }) + + return { + containerRef, + firstColumnWidth, + getDisplayedValue, + getFullName, + getPlayerParams, + handleScroll, + handleSortClick, + isExpanded, + paramColumnWidth, + params, + players, + showExpandButton, + showLeftArrow, + showRightArrow, + slideLeft, + slideRight, + sortCondition, + tableWrapperRef, + toggleIsExpanded, + } +} diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx new file mode 100644 index 00000000..ff2b8e3a --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/usePlayers.tsx @@ -0,0 +1,83 @@ +import { useMemo, useCallback } from 'react' + +import orderBy from 'lodash/orderBy' +import isNil from 'lodash/isNil' +import trim from 'lodash/trim' + +import type { Player, PlayerParam } 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 getDisplayedValue = ({ val }: PlayerParam) => (isNil(val) ? '-' : val) + + const getFullName = useCallback((player: Player) => ( + trim(`${player[`firstname_${suffix}`]} ${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, getFullName) + : orderBy( + players, + [ + (player) => { + const paramValue = getParamValue(player.id, sortCondition.paramId!) + + return isNil(paramValue) ? -1 : paramValue + }, + getFullName, + ], + sortCondition.dir, + ) + }, [ + getFullName, + getParamValue, + playersData, + matchProfile?.team1.id, + sortCondition.dir, + sortCondition.paramId, + teamId, + ]) + + return { + getDisplayedValue, + getFullName, + getPlayerParams, + isExpanded, + players: sortedPlayers, + toggleIsExpanded, + } +} diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx new file mode 100644 index 00000000..5bc2f55c --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/hooks/useTable.tsx @@ -0,0 +1,171 @@ +import type { + SyntheticEvent, + Dispatch, + SetStateAction, +} from 'react' +import { + useRef, + useState, + useEffect, + useMemo, +} from 'react' + +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 round from 'lodash/round' +import map from 'lodash/map' + +import { isMobileDevice } from 'config' + +import type { PlayerParam, PlayersStats } from 'requests' + +import { useToggle } from 'hooks' + +import { useMatchPageStore } from 'features/MatchPage/store' +import { useLexicsConfig } from 'features/LexicsStore' + +import type { SortCondition } from '../types' +import { + PARAM_COLUMN_WIDTH, + DISPLAYED_PARAMS_COLUMNS, + FIRST_COLUMN_WIDTH_DEFAULT, + SCROLLBAR_WIDTH, +} from '../config' + +type UseTableArgs = { + setSortCondition: Dispatch>, + teamId: number, +} + +type HeaderParam = Pick + +export const useTable = ({ + setSortCondition, + teamId, +}: UseTableArgs) => { + const containerRef = useRef(null) + const tableWrapperRef = useRef(null) + + const [showLeftArrow, setShowLeftArrow] = useState(false) + const [showRightArrow, setShowRightArrow] = useState(false) + + const { isOpen: isExpanded, toggle: toggleIsExpanded } = useToggle() + const { playersStats } = useMatchPageStore() + + const params = useMemo(() => ( + reduce>( + 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>( + 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 = () => { + const rest = ( + (containerRef.current?.clientWidth || 0) - FIRST_COLUMN_WIDTH_DEFAULT - SCROLLBAR_WIDTH + ) + const desktopWith = PARAM_COLUMN_WIDTH + const mobileWidth = paramsCount < DISPLAYED_PARAMS_COLUMNS ? 0 : rest / DISPLAYED_PARAMS_COLUMNS + + return isMobileDevice ? mobileWidth : desktopWith + } + + const getFirstColumnWidth = () => { + if (isExpanded) return 0 + + return paramsCount < DISPLAYED_PARAMS_COLUMNS ? 0 : FIRST_COLUMN_WIDTH_DEFAULT + } + + const paramColumnWidth = getParamColumnWidth() + const firstColumnWidth = getFirstColumnWidth() + + const slideLeft = () => tableWrapperRef.current?.scrollBy(-paramColumnWidth, 0) + const slideRight = () => tableWrapperRef.current?.scrollBy(paramColumnWidth, 0) + + const getDisplayedValue = ({ val }: PlayerParam) => (isNil(val) ? '-' : round(val, 2)) + + const handleScroll = (e: SyntheticEvent) => { + const { + clientWidth, + scrollLeft, + scrollWidth, + } = e.currentTarget + + const scrollRight = scrollWidth - (scrollLeft + clientWidth) + + setShowLeftArrow(scrollLeft > 0) + setShowRightArrow(scrollRight > 0) + } + + const handleSortClick = (paramId: number) => () => { + setSortCondition((curr) => ({ + dir: curr.dir === 'asc' || curr.paramId !== paramId ? 'desc' : 'asc', + paramId, + })) + } + + useEffect(() => { + const { + clientWidth = 0, + scrollLeft = 0, + scrollWidth = 0, + } = tableWrapperRef.current || {} + + const scrollRight = scrollWidth - (scrollLeft + clientWidth) + + setShowRightArrow(scrollRight > 0) + }, [isExpanded]) + + return { + containerRef, + firstColumnWidth, + getDisplayedValue, + handleScroll, + handleSortClick, + isExpanded, + paramColumnWidth, + params, + showExpandButton: !isMobileDevice && paramsCount > DISPLAYED_PARAMS_COLUMNS, + showLeftArrow, + showRightArrow, + slideLeft, + slideRight, + tableWrapperRef, + toggleIsExpanded, + } +} diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx new file mode 100644 index 00000000..9e5ce602 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/index.tsx @@ -0,0 +1,166 @@ +import { Fragment } from 'react' + +import map from 'lodash/map' +import includes from 'lodash/includes' + +import { PlayerParam } from 'requests' + +import { T9n } from 'features/T9n' + +import type { PlayersTableProps } from './types' +import { usePlayersTable } from './hooks' +import { + Container, + TableWrapper, + Table, + FirstColumn, + Cell, + Row, + PlayerNum, + PlayerNameWrapper, + PlayerName, + ParamShortTitle, + ArrowButtonRight, + ArrowButtonLeft, + Arrow, + ExpandButton, + Tooltip, +} from './styled' + +export const PlayersTable = (props: PlayersTableProps) => { + const { + containerRef, + firstColumnWidth, + getDisplayedValue, + getFullName, + getPlayerParams, + handleScroll, + handleSortClick, + isExpanded, + paramColumnWidth, + params, + players, + showExpandButton, + showLeftArrow, + showRightArrow, + slideLeft, + slideRight, + sortCondition, + tableWrapperRef, + toggleIsExpanded, + } = usePlayersTable(props) + + return ( + + + {!isExpanded && ( + + {showLeftArrow && ( + + + + )} + {showRightArrow && ( + + + + )} + + )} + + + + {showExpandButton && ( + + + + + )} + + + {map(players, (player) => { + const fullName = getFullName(player) + + return ( + + + + {player.club_shirt_num} + {' '} + + + {fullName} + + + {fullName} + + + + + ) + })} + + + + {map(params, ({ + id, + lexic, + lexica_short, + }) => ( + + + + + + + ))} + + + {map(players, (player) => ( + + {map(params, ({ id }) => { + const playerParam = getPlayerParams(player.id)[id] as PlayerParam | undefined + const value = playerParam ? getDisplayedValue(playerParam) : '-' + const clickable = Boolean(playerParam?.clickable) && !includes([0, '-'], value) + const sorted = sortCondition.paramId === id + + return ( + + {value} + + ) + })} + + ))} +
+
+
+ ) +} diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx new file mode 100644 index 00000000..a0d9f33d --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/styled.tsx @@ -0,0 +1,241 @@ +import styled, { css } from 'styled-components/macro' + +import { isMobileDevice } from 'config' + +import { customScrollbar } from 'features/Common' +import { TooltipWrapper } from 'features/Tooltip' +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` + ${({ isExpanded }) => (isExpanded + ? '' + : css` + position: relative; + `)} +` + +type TableWrapperProps = { + isExpanded?: boolean, +} + +export const TableWrapper = styled.div` + display: flex; + max-width: 100%; + max-height: calc(100vh - 235px); + border-radius: 5px; + overflow-x: auto; + scroll-behavior: smooth; + background-color: #333333; + z-index: 50; + ${customScrollbar} + + ${({ isExpanded }) => (isExpanded + ? css` + position: absolute; + right: 14px; + ` + : '')} +` + +export const Table = styled.div` + flex-grow: 1; + border-radius: 5px; + border-collapse: collapse; + letter-spacing: -0.078px; +` + +export const Tooltip = styled(TooltipWrapper)` + left: auto; + padding: 2px 10px; + border-radius: 6px; + transform: none; + font-size: 11px; + line-height: 1; + color: ${({ theme }) => theme.colors.black}; + + ::before { + display: none; + } +` + +export const ParamShortTitle = styled(T9n)` + text-transform: uppercase; +` + +export const Row = styled.div` + display: flex; + width: 100%; + height: 45px; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.5); + + :first-child { + position: sticky; + left: 0; + top: 0; + z-index: 1; + } +` + +type TdProps = { + clickable?: boolean, + columnWidth?: number, + headerCell?: boolean, + sorted?: boolean, +} + +export const Cell = styled.div.attrs(({ clickable }: TdProps) => ({ + ...clickable && { tabIndex: 0 }, +}))` + position: relative; + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + width: ${({ columnWidth }) => (columnWidth ? `${columnWidth}px` : 'auto')}; + font-size: 11px; + color: ${({ + clickable, + headerCell, + theme, + }) => (clickable && !headerCell ? '#5EB2FF' : theme.colors.white)}; + white-space: nowrap; + background-color: #333333; + + ${Tooltip} { + top: 35px; + } + + :hover { + ${Tooltip} { + display: block; + } + } + + ${({ headerCell }) => (headerCell + ? '' + : css` + :first-child { + justify-content: unset; + padding-left: 13px; + color: ${({ theme }) => theme.colors.white}; + } + `)} + + ${({ sorted }) => (sorted + ? css` + font-weight: bold; + ` + : '')} + + ${({ clickable, headerCell }) => (clickable || headerCell + ? css` + cursor: pointer; + ` + : '')} +` + +type FirstColumnProps = { + columnWidth?: number, +} + +export const FirstColumn = styled.div` + position: sticky; + left: 0; + width: ${({ columnWidth }) => (columnWidth ? `${columnWidth}px` : 'auto')}; +` + +export const PlayerNum = styled.span` + display: inline-block; + width: 20px; + flex-shrink: 0; + text-align: center; + color: rgba(255, 255, 255, 0.5); +` + +type PlayerNameProps = { + columnWidth?: number, +} + +export const PlayerName = styled.span` + display: inline-block; + margin-top: 2px; + text-overflow: ellipsis; + overflow: hidden; + + ${({ columnWidth }) => (columnWidth + ? css` + max-width: calc(${columnWidth}px - 31px); + ` + : css` + max-width: 110px; + `)} +` + +export const PlayerNameWrapper = styled.span` + position: relative; + + ${Tooltip} { + top: 15px; + } + + :hover { + ${Tooltip} { + display: block; + } + } +` + +const ArrowButton = styled(ArrowButtonBase)` + position: absolute; + width: 17px; + margin-top: 2px; + background-color: #333333; + z-index: 3; + + ${isMobileDevice + ? css` + height: 45px; + margin-top: 0; + ` + : ''}; +` + +export const ArrowButtonRight = styled(ArrowButton)` + right: 0; +` + +export const ArrowButtonLeft = styled(ArrowButton)` + left: 75px; +` + +export const Arrow = styled(ArrowBase)` + width: 10px; + height: 10px; + + ${isMobileDevice + ? css` + border-color: ${({ theme }) => theme.colors.white}; + ` + : ''}; +` + +export const ExpandButton = styled(ArrowButton)` + left: 20px; + top: 0; + + ${Arrow} { + left: 0; + + :last-child { + margin-left: 7px; + } + } +` diff --git a/src/features/MatchSidePlaylists/components/PlayersTable/types.tsx b/src/features/MatchSidePlaylists/components/PlayersTable/types.tsx new file mode 100644 index 00000000..85b9d054 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/PlayersTable/types.tsx @@ -0,0 +1,8 @@ +export type PlayersTableProps = { + teamId: number, +} + +export type SortCondition = { + dir: 'asc' | 'desc', + paramId: number | null, +} diff --git a/src/features/MatchSidePlaylists/components/TabPlayers/index.tsx b/src/features/MatchSidePlaylists/components/TabPlayers/index.tsx new file mode 100644 index 00000000..bd8244ac --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabPlayers/index.tsx @@ -0,0 +1,31 @@ +import isEmpty from 'lodash/isEmpty' + +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) => { + if (isEmpty(playlists.players.team1)) return null + + return ( + + ) +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/config.tsx b/src/features/MatchSidePlaylists/components/TabStats/config.tsx new file mode 100644 index 00000000..3c14e7ff --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/config.tsx @@ -0,0 +1,10 @@ +export enum Tabs { + TEAMS, + TEAM1, + TEAM2, +} + +export enum StatsType { + FINAL_STATS, + CURRENT_STATS, +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/hooks.tsx b/src/features/MatchSidePlaylists/components/TabStats/hooks.tsx new file mode 100644 index 00000000..1d621ce3 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/hooks.tsx @@ -0,0 +1,68 @@ +import { useEffect, useState } from 'react' + +import isEmpty from 'lodash/isEmpty' + +import { useMatchPageStore } from 'features/MatchPage/store' + +import { StatsType, Tabs } from './config' + +export const useTabStats = () => { + const [selectedTab, setSelectedTab] = useState(Tabs.TEAMS) + + const { + isEmptyPlayersStats, + profile: matchProfile, + setStatsType, + statsType, + teamsStats, + } = useMatchPageStore() + + const isFinalStatsType = statsType === StatsType.FINAL_STATS + + const switchTitleLexic = isFinalStatsType ? 'final_stats' : 'current_stats' + const tooltipLexic = 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), + ) + + const toggleStatsType = () => { + const newStatsType = isFinalStatsType ? StatsType.CURRENT_STATS : StatsType.FINAL_STATS + + setStatsType(newStatsType) + } + + 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, + isVisibleTeam1PlayersTab, + isVisibleTeam2PlayersTab, + isVisibleTeamsTab, + selectedTab, + setSelectedTab, + switchTitleLexic, + toggleStatsType, + tooltipLexic, + } +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/index.tsx b/src/features/MatchSidePlaylists/components/TabStats/index.tsx new file mode 100644 index 00000000..d5b20d74 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/index.tsx @@ -0,0 +1,103 @@ +import { isMobileDevice } from 'config/userAgent' + +import { getTeamAbbr } from 'helpers' + +import { Tooltip } from 'features/Tooltip' +import { T9n } from 'features/T9n' +import { useMatchPageStore } from 'features/MatchPage/store' +import { Name } from 'features/Name' + +import { Tabs } from './config' +import { useTabStats } from './hooks' +import { PlayersTable } from '../PlayersTable' +import { TeamsStatsTable } from '../TeamsStatsTable' + +import { + Container, + Header, + TabList, + Tab, + Switch, + SwitchTitle, + SwitchButton, +} from './styled' + +const tabPanes = { + [Tabs.TEAMS]: TeamsStatsTable, + [Tabs.TEAM1]: PlayersTable, + [Tabs.TEAM2]: PlayersTable, +} + +export const TabStats = () => { + const { + isFinalStatsType, + isVisibleTeam1PlayersTab, + isVisibleTeam2PlayersTab, + isVisibleTeamsTab, + selectedTab, + setSelectedTab, + switchTitleLexic, + toggleStatsType, + tooltipLexic, + } = useTabStats() + const { profile: matchProfile } = useMatchPageStore() + + const TabPane = tabPanes[selectedTab] + + if (!matchProfile) return null + + const { team1, team2 } = matchProfile + + return ( + +
+ + {isVisibleTeamsTab && ( + setSelectedTab(Tabs.TEAMS)} + > + + + )} + {isVisibleTeam1PlayersTab && ( + setSelectedTab(Tabs.TEAM1)} + > + + + )} + {isVisibleTeam2PlayersTab && ( + setSelectedTab(Tabs.TEAM2)} + > + + + )} + + + + + {!isMobileDevice && } + + +
+ +
+ ) +} diff --git a/src/features/MatchSidePlaylists/components/TabStats/styled.tsx b/src/features/MatchSidePlaylists/components/TabStats/styled.tsx new file mode 100644 index 00000000..13318a33 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TabStats/styled.tsx @@ -0,0 +1,110 @@ +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: 23px; +` + +export const TabList = styled.div.attrs({ role: 'tablist' })` + display: flex; +` + +export const Tab = styled.button.attrs({ role: 'tab' })` + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 10px 10px; + font-size: 12px; + color: ${({ theme }) => theme.colors.white}; + opacity: 0.4; + cursor: pointer; + border: none; + background: none; + border-bottom: 2px solid transparent; + + &[aria-pressed="true"] { + opacity: 1; + border-color: currentColor; + } +` + +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` + position: relative; + width: 20px; + height: 7px; + margin-left: 5px; + margin-top: 5px; + border-radius: 2px; + border: none; + border: 1px solid ${({ theme }) => theme.colors.white}; + cursor: pointer; + + ${TooltipWrapper} { + left: auto; + right: 0; + top: 15px; + padding: 2px 10px; + border-radius: 6px; + transform: none; + font-size: 11px; + line-height: 1; + + ::before { + display: none; + } + } + + :hover { + ${TooltipWrapper} { + display: block; + } + } + + ${({ isFinalStatsType, theme }) => (!isFinalStatsType + ? css` + background-image: linear-gradient( + to right, + ${theme.colors.white} 33.333%, + ${theme.colors.black} 33.333%, + ${theme.colors.black} 66.666%, + ${theme.colors.white} 66.666%, + ${theme.colors.white} 72%, + ${theme.colors.black} 72%, + ${theme.colors.black} 100%) + ` + : css` + border-color: transparent; + background-image: linear-gradient( + to right, + ${theme.colors.white} 33.333%, + ${theme.colors.black} 33.333%, + ${theme.colors.black} 38%, + ${theme.colors.white} 38%, + ${theme.colors.white} 66.666%, + ${theme.colors.black} 66.666%, + ${theme.colors.black} 72%, + ${theme.colors.white} 72%, + ${theme.colors.white} 100%) + ` + )} +` diff --git a/src/features/MatchSidePlaylists/components/TabWatch/index.tsx b/src/features/MatchSidePlaylists/components/TabWatch/index.tsx index d6b94831..368e957e 100644 --- a/src/features/MatchSidePlaylists/components/TabWatch/index.tsx +++ b/src/features/MatchSidePlaylists/components/TabWatch/index.tsx @@ -1,53 +1,79 @@ -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_ITEM_INDENT } from '../MatchPlaylists' import { SideInterviews } from '../SideInterviews' -import { PlayersPlaylists } from '../PlayersPlaylists' +import { Matches } from '../Matches' type Props = { onSelect: (option: PlaylistOption) => void, + playListFilter: number, playlists: Playlists, profile: MatchInfo, selectedPlaylist?: PlaylistOption, + tournamentData: TournamentData, } export const TabWatch = ({ onSelect, + playListFilter, playlists, profile, selectedPlaylist, -}: Props) => ( - - - - - - {!isEmpty(playlists.players.team1) && ( - { + const matchPlaylistsRef = useRef(null) + + const additionalScrollHeight = (matchPlaylistsRef.current?.clientHeight || 0) + LIST_ITEM_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 ( + + {playListFilter > 1 && ( + + )} + + + + - )} - -) + + ) +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx new file mode 100644 index 00000000..d4698aea --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/hooks.tsx @@ -0,0 +1,31 @@ +import isNumber from 'lodash/isNumber' +import find from 'lodash/find' +import round from 'lodash/round' + +import type { Param } from 'requests' + +import { useMatchPageStore } from 'features/MatchPage/store' + +export const useTeamsStatsTable = () => { + const { profile, teamsStats } = useMatchPageStore() + + const getDisplayedValue = (val: any) => ( + isNumber(val) ? round(val, 2) : '-' + ) + + const getStatItemById = (paramId: number) => { + if (!profile) return null + + return find(teamsStats[profile?.team2.id], ({ param1 }) => param1.id === paramId) || null + } + + const isClickable = (param: Param) => ( + Boolean(param.val) && param.clickable + ) + + return { + getDisplayedValue, + getStatItemById, + isClickable, + } +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx new file mode 100644 index 00000000..610d2c8c --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/index.tsx @@ -0,0 +1,93 @@ +import { Fragment } from 'react' + +import map from 'lodash/map' + +import { useMatchPageStore } from 'features/MatchPage/store' +import { useLexicsStore } from 'features/LexicsStore' + +import { useTeamsStatsTable } from './hooks' +import { + Container, + Row, + TeamShortName, + ParamValueContainer, + ParamValue, + StatItemTitle, + Divider, +} from './styled' + +export const TeamsStatsTable = () => { + const { profile, teamsStats } = useMatchPageStore() + const { + getDisplayedValue, + getStatItemById, + isClickable, + } = useTeamsStatsTable() + const { lang } = useLexicsStore() + + if (!profile) return null + + return ( + + + + + + + {map(teamsStats[profile.team1.id], (team1StatItem) => { + const team2StatItem = getStatItemById(team1StatItem.param1.id) + const statItemTitle = team1StatItem[`name_${lang === 'ru' ? 'ru' : 'en'}`] + + return ( + + + + {getDisplayedValue(team1StatItem.param1.val)} + + {team1StatItem.param2 && ( + + / + + {getDisplayedValue(team1StatItem.param2.val)} + + + )} + + + {statItemTitle} + + {team2StatItem && ( + + + {getDisplayedValue(team2StatItem.param1.val)} + + {team2StatItem.param2 && ( + + / + + {getDisplayedValue(team2StatItem.param2.val)} + + + )} + + )} + + ) + })} + + ) +} diff --git a/src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx b/src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx new file mode 100644 index 00000000..39b3a3c7 --- /dev/null +++ b/src/features/MatchSidePlaylists/components/TeamsStatsTable/styled.tsx @@ -0,0 +1,65 @@ +import styled, { css } from 'styled-components/macro' + +import { Name } from 'features/Name' + +export const Container = styled.div` + width: 100%; + font-size: 11px; + overflow: hidden; + border-radius: 5px; + background-color: #333333; +` + +export const TeamShortName = styled(Name)` + color: ${({ theme }) => theme.colors.white}; + letter-spacing: -0.078px; + text-transform: uppercase; + font-weight: 600; + opacity: 0.5; +` + +export const Row = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + height: 45px; + padding: 0 12px; + border-bottom: 0.5px solid rgba(255, 255, 255, 0.5); + + :last-child { + border-bottom: none; + } +` + +export const ParamValueContainer = styled.div`` + +type TParamValue = { + clickable?: boolean, +} + +export const ParamValue = styled.span.attrs(({ clickable }: TParamValue) => ({ + ...clickable && { tabIndex: 0 }, +}))` + font-weight: 600; + color: ${({ clickable, theme }) => (clickable ? '#5EB2FF' : theme.colors.white)}; + + ${({ clickable }) => (clickable + ? css` + cursor: pointer; + ` + : '')} +` + +export const StatItemTitle = styled.span` + color: ${({ theme }) => theme.colors.white}; + letter-spacing: -0.078px; + text-transform: uppercase; + font-weight: 600; + opacity: 0.5; +` + +export const Divider = styled.span` + color: ${({ theme }) => theme.colors.white}; + opacity: 0.5; + font-weight: 600; +` diff --git a/src/features/MatchSidePlaylists/config.tsx b/src/features/MatchSidePlaylists/config.tsx index 9ab22db3..fc7219fd 100644 --- a/src/features/MatchSidePlaylists/config.tsx +++ b/src/features/MatchSidePlaylists/config.tsx @@ -1,5 +1,6 @@ export enum Tabs { WATCH, EVENTS, - VIDEO + STATS, + PLAYERS, } diff --git a/src/features/MatchSidePlaylists/hooks.tsx b/src/features/MatchSidePlaylists/hooks.tsx index a953144c..17e889c5 100644 --- a/src/features/MatchSidePlaylists/hooks.tsx +++ b/src/features/MatchSidePlaylists/hooks.tsx @@ -5,6 +5,8 @@ import { } 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 +16,53 @@ export const useMatchSidePlaylists = () => { const { closePopup, events, + isEmptyPlayersStats, matchPlaylists: playlists, + profile: matchProfile, + teamsStats, tournamentData, } = useMatchPageStore() const [selectedTab, setSelectedTab] = useState(Tabs.WATCH) - const isWatchTabVisible = useMemo(() => { - const playListFilter = reduce( - playlists.match, - (acc, item) => { - let result = acc - if (item.duration) result++ - return result - }, - 0, - ) - return playListFilter > 1 - }, [playlists]) + + const playListFilter = useMemo(() => reduce( + playlists.match, + (acc, item) => { + let result = acc + if (item.duration) result++ + return result + }, + 0, + ), [playlists.match]) + + const isWatchTabVisible = useMemo(() => ( + playListFilter > 1 || tournamentData.matchDates.length > 1 + ), [playListFilter, tournamentData.matchDates.length]) 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,21 +72,32 @@ 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, + ]) useEffect(() => { if (selectedTab !== Tabs.EVENTS) closePopup() }, [selectedTab, closePopup]) return { + hasLessThanFourTabs, isEventTabVisible, - isVideoTabVisible, + isPlayersTabVisible, + isStatsTabVisible, isWatchTabVisible, onTabClick: setSelectedTab, + playListFilter, selectedTab, } } diff --git a/src/features/MatchSidePlaylists/index.tsx b/src/features/MatchSidePlaylists/index.tsx index b3a0edc9..e50cafe2 100644 --- a/src/features/MatchSidePlaylists/index.tsx +++ b/src/features/MatchSidePlaylists/index.tsx @@ -6,8 +6,6 @@ import { 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,18 +15,24 @@ 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 = { @@ -53,10 +57,13 @@ export const MatchSidePlaylists = ({ } = useMatchPageStore() const { + hasLessThanFourTabs, isEventTabVisible, - isVideoTabVisible, + isPlayersTabVisible, + // isStatsTabVisible, isWatchTabVisible, onTabClick, + playListFilter, selectedTab, } = useMatchSidePlaylists() @@ -99,38 +106,50 @@ export const MatchSidePlaylists = ({ return ( - + {isWatchTabVisible ? ( onTabClick(Tabs.WATCH)} > - + + ) : null} {isEventTabVisible ? ( onTabClick(Tabs.EVENTS)} > - + + ) : null} - {isVideoTabVisible ? ( + {isPlayersTabVisible ? ( onTabClick(Tabs.VIDEO)} + aria-pressed={selectedTab === Tabs.PLAYERS} + onClick={() => onTabClick(Tabs.PLAYERS)} > - + + ) : null} + {/* {isStatsTabVisible ? ( + onTabClick(Tabs.STATS)} + > + + + + ) : null} */} diff --git a/src/features/MatchSidePlaylists/styled.tsx b/src/features/MatchSidePlaylists/styled.tsx index 17cc8d98..0ba4d49a 100644 --- a/src/features/MatchSidePlaylists/styled.tsx +++ b/src/features/MatchSidePlaylists/styled.tsx @@ -4,6 +4,7 @@ import { devices } from 'config/devices' import { isMobileDevice } from 'config/userAgent' import { customScrollbar } from 'features/Common' +import { T9n } from 'features/T9n' export const Wrapper = styled.div` padding-right: 14px; @@ -19,7 +20,7 @@ export const Wrapper = styled.div` ` export const TabsWrapper = styled.div` - padding-left: 14px; + padding: 0 30px; ${isMobileDevice ? css` @@ -28,8 +29,81 @@ export const TabsWrapper = styled.div` : ''}; ` +type TabsGroupProps = { + hasLessThanFourTabs?: boolean, +} + +export const TabsGroup = styled.div.attrs({ role: 'tablist' })` + display: flex; + height: 45px; + padding-top: 10px; + + ${({ hasLessThanFourTabs }) => (hasLessThanFourTabs + ? css` + height: 40px; + + ${Tab} { + justify-content: center; + flex-direction: row; + gap: 5px; + } + ` + : '')} + + ${isMobileDevice + ? css` + ` + : ''}; +` + +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; + flex: 1; + opacity: 0.4; + cursor: pointer; + border: none; + background: none; + + &[aria-pressed="true"], :hover { + opacity: 1; + + ${TabTitle} { + font-weight: 600; + } + } +` + +type TabIconProps = { + icon: 'watch' | 'plays' | 'players' | 'stats', +} + +export const TabIcon = styled.div` + width: 22px; + height: 22px; + background-image: url(/images/matchTabs/${({ icon }) => `${icon}.svg`}); + background-repeat: no-repeat; + background-position: center; + background-size: contain; + + ${({ icon }) => (icon === 'players' + ? css` + background-size: 23px; + ` + : '')} +` + type TContainer = { - forVideoTab?: boolean, + forWatchTab?: boolean, hasScroll: boolean, } @@ -37,14 +111,13 @@ export const Container = styled.div` width: 320px; margin-top: 14px; max-height: calc(100vh - 130px); - overflow-y: ${({ forVideoTab }) => (forVideoTab ? 'hidden' : 'auto')}; - padding-right: ${({ forVideoTab }) => (forVideoTab ? '0' : '')}; + overflow-y: ${({ forWatchTab }) => (forWatchTab ? 'hidden' : 'auto')}; + padding-right: ${({ forWatchTab }) => (forWatchTab ? '0' : '')}; padding-left: 14px; padding-right: ${({ hasScroll }) => (hasScroll ? '10px' : '')}; ${customScrollbar} - @media ${devices.tablet} { margin-top: 15px; } diff --git a/src/features/MultiSourcePlayer/hooks/index.tsx b/src/features/MultiSourcePlayer/hooks/index.tsx index 63c0d73a..c4e596f8 100644 --- a/src/features/MultiSourcePlayer/hooks/index.tsx +++ b/src/features/MultiSourcePlayer/hooks/index.tsx @@ -14,9 +14,18 @@ import { useVolume } from 'features/VideoPlayer/hooks/useVolume' import { useNoNetworkPopupStore } from 'features/NoNetworkPopup' import { useMatchPageStore } from 'features/MatchPage/store' -import { useEventListener, useObjectState } from 'hooks' +import { + useEventListener, + useInterval, + useObjectState, + usePageParams, +} from 'hooks' -import { MatchInfo } from 'requests' +import { + MatchInfo, + saveMatchStats, + VIEW_INTERVAL_MS, +} from 'requests' import { useProgressChangeHandler } from './useProgressChangeHandler' import { usePlayingHandlers } from './usePlayingHandlers' @@ -47,6 +56,7 @@ export type Props = { chapters: Chapters, isOpenPopup?: boolean, onError?: () => void, + onPlayerProgressChange?: (ms: number) => void, onPlayingChange: (playing: boolean) => void, profile: MatchInfo, setCircleAnimation: TSetCircleAnimation, @@ -55,6 +65,7 @@ export type Props = { export const useMultiSourcePlayer = ({ chapters, onError, + onPlayerProgressChange, onPlayingChange, setCircleAnimation, }: Props) => { @@ -63,6 +74,11 @@ export const useMultiSourcePlayer = ({ playNextEpisode, } = useMatchPageStore() + const { profileId, sportType } = usePageParams() + + /** время для сохранения статистики просмотра матча */ + const timeForStatistics = useRef(0) + const numberOfChapters = size(chapters) const [ { @@ -184,7 +200,10 @@ export const useMultiSourcePlayer = ({ const chapter = getActiveChapter() const value = Math.max(playedMs - chapter.startOffsetMs, 0) + timeForStatistics.current = (value + chapter.startMs) / 1000 + setPlayerState({ playedProgress: value }) + onPlayerProgressChange?.(playedMs + chapter.startMs) } const onEnded = () => { @@ -267,6 +286,27 @@ export const useMultiSourcePlayer = ({ } }, [ready, videoRef]) + // ведем статистику просмотра матча + const { start: startCollectingStats, stop: stopCollectingStats } = useInterval({ + callback: () => { + saveMatchStats({ + matchId: profileId, + matchSecond: timeForStatistics.current, + sportType, + }) + }, + intervalDuration: VIEW_INTERVAL_MS, + startImmediate: false, + }) + + useEffect(() => { + if (playing) { + startCollectingStats() + } else { + stopCollectingStats() + } + }, [playing, startCollectingStats, stopCollectingStats]) + return { activeChapterIndex, activePlayer, diff --git a/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx b/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx index a98c2f18..5c0f2d7e 100644 --- a/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx +++ b/src/features/StreamPlayer/components/Controls/Components/ControlsWeb/index.tsx @@ -114,7 +114,7 @@ export const ControlsWeb = (controlsProps: { props: ControlsPropsExtended }) => )} {document.pictureInPictureEnabled && ( - + )} { - const { isOpenPopup, profile } = useMatchPageStore() + const { isOpenFiltersPopup, profile } = useMatchPageStore() const { onMouseMove, @@ -34,7 +34,7 @@ export const YoutubePlayer = (props: Props) => { onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} > - {isOpenPopup && } + {isOpenFiltersPopup && } { + saveMatchStats({ + matchId: profileId, + matchSecond: timeForStatistics.current, + sportType, + }) + }, + intervalDuration: VIEW_INTERVAL_MS, + startImmediate: false, + }) + + useEffect(() => { + if (playing) { + startCollectingStats() + } else { + stopCollectingStats() + } + }, [playing, startCollectingStats, stopCollectingStats]) + return { activeChapterIndex, allPlayedProgress: playedProgress + getActiveChapter().startMs, diff --git a/src/features/StreamPlayer/index.tsx b/src/features/StreamPlayer/index.tsx index 5a4b6bda..b4ca80b6 100644 --- a/src/features/StreamPlayer/index.tsx +++ b/src/features/StreamPlayer/index.tsx @@ -30,7 +30,7 @@ import RewindMobile from './components/RewindMobile' * HLS плеер, применяется на лайв и завершенных матчах */ export const StreamPlayer = (props: Props) => { - const { isOpenPopup, profile } = useMatchPageStore() + const { isOpenFiltersPopup, profile } = useMatchPageStore() const { user } = useAuthStore() const { @@ -96,7 +96,7 @@ export const StreamPlayer = (props: Props) => { onTouchStart={onTouchStart} onTouchEnd={onTouchEnd} > - {isOpenPopup && } + {isOpenFiltersPopup && } diff --git a/src/features/Tooltip/index.tsx b/src/features/Tooltip/index.tsx index b0683b49..a436eb2d 100644 --- a/src/features/Tooltip/index.tsx +++ b/src/features/Tooltip/index.tsx @@ -25,7 +25,7 @@ export const TooltipWrapper = styled(TooltipBlockWrapper)` ` type Props = { - children: ReactNode, + children?: ReactNode, lexic: string, } diff --git a/src/features/UserAccount/index.tsx b/src/features/UserAccount/index.tsx index a1fd24bd..553a5524 100644 --- a/src/features/UserAccount/index.tsx +++ b/src/features/UserAccount/index.tsx @@ -77,7 +77,7 @@ const UserAccount = () => { {!isLffClient && !isInSportsClient && ( diff --git a/src/helpers/getTeamAbbr/index.tsx b/src/helpers/getTeamAbbr/index.tsx new file mode 100644 index 00000000..62a1eb51 --- /dev/null +++ b/src/helpers/getTeamAbbr/index.tsx @@ -0,0 +1,25 @@ +import toUpper from 'lodash/toUpper' +import split from 'lodash/split' +import size from 'lodash/size' + +import pipe from 'lodash/fp/pipe' +import take from 'lodash/fp/take' +import join from 'lodash/fp/join' +import map from 'lodash/fp/map' + +export const getTeamAbbr = (teamName: string) => { + const nameParts = split(teamName, ' ') + + return size(nameParts) > 1 + ? pipe( + map(take(1)), + join(''), + toUpper, + )(nameParts) + + : pipe( + take(3), + join(''), + toUpper, + )(nameParts[0]) +} diff --git a/src/helpers/index.tsx b/src/helpers/index.tsx index 734a5130..0577a810 100644 --- a/src/helpers/index.tsx +++ b/src/helpers/index.tsx @@ -8,3 +8,4 @@ export * from './secondsToHms' export * from './redirectToUrl' export * from './getRandomString' export * from './selectedApi' +export * from './getTeamAbbr' diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index f318328b..8219ac0e 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -4,3 +4,4 @@ export * from './useStorage' export * from './useInterval' export * from './useEventListener' export * from './useObjectState' +export * from './usePageParams' diff --git a/src/hooks/usePageParams.tsx b/src/hooks/usePageParams.tsx index 4810663c..4d1df1c7 100644 --- a/src/hooks/usePageParams.tsx +++ b/src/hooks/usePageParams.tsx @@ -22,6 +22,7 @@ export const usePageParams = () => { return { profileId: Number(pageId), profileType: ProfileTypes[toUpper(profileName) as keyof typeof ProfileTypes], + sportName, sportType: SportTypes[toUpper(sportName) as keyof typeof SportTypes], } } diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 65952d05..9740e798 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -3,7 +3,7 @@ declare namespace NodeJS { export interface ProcessEnv { - REACT_APP_CLIENT: 'instat' | 'facr' | 'lff' | 'insports', + REACT_APP_CLIENT: 'instat' | 'facr' | 'lff' | 'insports' | 'india' | 'tunis', REACT_APP_ENV: 'production' | 'preproduction' | 'staging', REACT_APP_STAGE: 'staging' | 'test-a' | 'test-b' | 'test-c' | 'test-d' | 'test-e' | 'test-f' | 'test-g' | 'test-h' | 'test-i' | 'test-j' | 'test', REACT_APP_TYPE: 'auth-service' | 'ott', diff --git a/src/requests/getMatchParticipants.tsx b/src/requests/getMatchParticipants.tsx new file mode 100644 index 00000000..645c3c03 --- /dev/null +++ b/src/requests/getMatchParticipants.tsx @@ -0,0 +1,65 @@ +import isUndefined from 'lodash/isUndefined' + +import { SportTypes } 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, + weight: number | null, +} + +type DataItem = { + players: Array, + team_id: number, +} + +type Response = { + data?: Array, + error?: { + code: string, + message: string, + }, +} + +type GetMatchParticipantsArgs = { + matchId: number, + second?: number, + sportType: SportTypes, +} + +export const getMatchParticipants = async ({ + matchId, + second, + sportType, +}: GetMatchParticipantsArgs) => { + const config = { + method: 'GET', + } + + const response: Response = await callApi({ + config, + url: `http://136.243.17.103:8888/ask/participants?sport_id=${sportType}&match_id=${matchId}${isUndefined(second) ? '' : `&second=${second}`}`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || []) +} diff --git a/src/requests/getPlayersStats.tsx b/src/requests/getPlayersStats.tsx new file mode 100644 index 00000000..3fb5d93f --- /dev/null +++ b/src/requests/getPlayersStats.tsx @@ -0,0 +1,54 @@ +import isUndefined from 'lodash/isUndefined' + +import { callApi } from 'helpers' + +export type PlayerParam = { + clickable: boolean, + data_type: string, + id: number, + lexic: number, + lexica_short: number | null, + markers: Array | null, + name_en: string, + name_ru: string, + val: number | null, +} + +export type PlayersStats = { + [playerId: string]: { + [paramId: string]: PlayerParam, + }, +} + +type Response = { + data?: PlayersStats, + error?: string, + message?: string, +} + +type GetPlayersStatsArgs = { + matchId: number, + second?: number, + sportName: string, + teamId: number, +} + +export const getPlayersStats = async ({ + matchId, + second, + sportName, + teamId, +}: GetPlayersStatsArgs) => { + const config = { + method: 'GET', + } + + const response: Response = await callApi({ + config, + url: `http://136.243.17.103:8888/${sportName}/matches/${matchId}/teams/${teamId}/players/stats${isUndefined(second) ? '' : `?second=${second}`}`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || {}) +} diff --git a/src/requests/getTeamsStats.tsx b/src/requests/getTeamsStats.tsx new file mode 100644 index 00000000..acdf40bd --- /dev/null +++ b/src/requests/getTeamsStats.tsx @@ -0,0 +1,56 @@ +import isUndefined from 'lodash/isUndefined' + +import { callApi } from 'helpers' + +export type Param = { + clickable: boolean, + data_type: string, + id: number, + lexic: number, + markers: Array, + name_en: string, + name_ru: string, + val: number | null, +} + +export type TeamStatItem = { + lexic: number, + name_en: string, + name_ru: string, + order: number, + param1: Param, + param2: Param | null, +} + +type Response = { + data?: { + [teamId: string]: Array, + }, + error?: string, + message?: string, +} + +type GetTeamsStatsArgs = { + matchId: number, + second?: number, + sportName: string, +} + +export const getTeamsStats = async ({ + matchId, + second, + sportName, +}: GetTeamsStatsArgs) => { + const config = { + method: 'GET', + } + + const response: Response = await callApi({ + config, + url: `http://136.243.17.103:8888/${sportName}/matches/${matchId}/teams/stats?group_num=0${isUndefined(second) ? '' : `&second=${second}`}`, + }) + + if (response.error) Promise.reject(response) + + return Promise.resolve(response.data || {}) +} diff --git a/src/requests/getUserInfo.tsx b/src/requests/getUserInfo.tsx index db8c30a2..e02528b6 100644 --- a/src/requests/getUserInfo.tsx +++ b/src/requests/getUserInfo.tsx @@ -18,6 +18,7 @@ export type UserInfo = { }, email: string, firstname: string | null, + has_subscription: boolean, is_unsubscribed: boolean | null, language: { id: number | null, diff --git a/src/requests/index.tsx b/src/requests/index.tsx index f4f0e777..2e54b0ae 100644 --- a/src/requests/index.tsx +++ b/src/requests/index.tsx @@ -24,3 +24,7 @@ export * from './getMatchPlaylists' export * from './getPlayerPlaylists' export * from './getSubscriptions' export * from './buySubscription' +export * from './saveMatchStats' +export * from './getTeamsStats' +export * from './getPlayersStats' +export * from './getMatchParticipants' diff --git a/src/requests/saveMatchStats.tsx b/src/requests/saveMatchStats.tsx new file mode 100644 index 00000000..fba03804 --- /dev/null +++ b/src/requests/saveMatchStats.tsx @@ -0,0 +1,30 @@ +import { SportTypes, VIEWS_API } from 'config' + +import { callApi } from 'helpers' + +type Props = { + matchId: number, + matchSecond: number, + sportType: SportTypes, +} + +export const VIEW_INTERVAL_MS = 5000 + +export const saveMatchStats = ({ + matchId, + matchSecond, + sportType, +}: Props) => { + const url = `${VIEWS_API}/user/view` + + const config = { + body: { + interval: VIEW_INTERVAL_MS / 1000, + match_id: matchId, + second: matchSecond, + sport_id: sportType, + }, + } + + return callApi({ config, url }) +}