diff --git a/public/images/player.svg b/public/images/player.svg new file mode 100644 index 00000000..7b6e6027 --- /dev/null +++ b/public/images/player.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/public/images/search.svg b/public/images/search.svg new file mode 100644 index 00000000..4890f336 --- /dev/null +++ b/public/images/search.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/team.svg b/public/images/team.svg new file mode 100644 index 00000000..a44b3e10 --- /dev/null +++ b/public/images/team.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/public/images/tournament.svg b/public/images/tournament.svg new file mode 100644 index 00000000..062ffeed --- /dev/null +++ b/public/images/tournament.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/config/colors.tsx b/src/config/colors.tsx new file mode 100644 index 00000000..ef4a7dd8 --- /dev/null +++ b/src/config/colors.tsx @@ -0,0 +1,5 @@ +export const SPORT_COLORS = { + basketball: '#f1903b', + football: '#00a435', + hockey: '#5eb1ff', +} diff --git a/src/config/index.tsx b/src/config/index.tsx index e5851bad..371ac95d 100644 --- a/src/config/index.tsx +++ b/src/config/index.tsx @@ -2,3 +2,4 @@ export * from './routes' export * from './pages' export * from './authKeys' export * from './procedures' +export * from './colors' diff --git a/src/config/lexics/authenticated.tsx b/src/config/lexics/authenticated.tsx index 67ae1d09..f2515ddd 100644 --- a/src/config/lexics/authenticated.tsx +++ b/src/config/lexics/authenticated.tsx @@ -1,4 +1,10 @@ export const authenticatedLexics = { + basketball: 6960, + football: 6958, + hockey: 6959, logout: 4306, + player: 630, + team: 658, + tournament: 1009, user_account: 12928, } diff --git a/src/config/procedures.tsx b/src/config/procedures.tsx index cbfb8f86..3a52bd7a 100644 --- a/src/config/procedures.tsx +++ b/src/config/procedures.tsx @@ -2,6 +2,7 @@ export const PROCEDURES = { auth_user: 'auth_user', create_user: 'create_user', get_cities: 'get_cities', + get_players_teams_tournaments: 'get_players_teams_tournaments', lst_c_country: 'lst_c_country', param_lexical: 'param_lexical', } diff --git a/src/config/routes.tsx b/src/config/routes.tsx index 8d513d50..f6ddfa14 100644 --- a/src/config/routes.tsx +++ b/src/config/routes.tsx @@ -1,2 +1,22 @@ export const API_ROOT = 'http://api-staging.instat.tv' export const DATA_URL = `${API_ROOT}/data` + +export const LOGOS_FALLBACKS = { + basketball: { + players: 'https://basketball.instatscout.com/images/player-no-photo.png', + teams: 'https://basketball.instatscout.com/images/team-no-photo.png', + tournaments: 'https://basketball.instatscout.com/images/tournaments/180/no-photo.png', + }, + + football: { + players: 'https://football.instatscout.com/images/player-no-photo.png', + teams: 'https://football.instatscout.com/images/team-no-photo.png', + tournaments: 'https://football.instatscout.com/images/tournament-no-photo.png', + }, + + hockey: { + players: 'https://hockey.instatscout.com/images/player-no-photo.png', + teams: 'https://hockey.instatscout.com/images/team-no-photo.png', + tournaments: 'https://hockey.instatscout.com/images/tournaments/180/no-photo.png', + }, +} diff --git a/src/features/Combobox/styled.tsx b/src/features/Combobox/styled.tsx index ee108522..068f4ab7 100644 --- a/src/features/Combobox/styled.tsx +++ b/src/features/Combobox/styled.tsx @@ -9,6 +9,7 @@ import { } from '@reach/combobox' import { wrapperStyles, inputStyles } from 'features/Common/Input/styled' +import { сustomScrollbar } from 'features/Common' export const ComboboxStyled = styled(Combobox)` ${wrapperStyles} @@ -53,18 +54,7 @@ export const ComboboxListStyled = styled(ComboboxList)` left: -164px; overflow: auto; - ::-webkit-scrollbar { - width: 8px; - } - - ::-webkit-scrollbar-track { - background: transparent; - } - - ::-webkit-scrollbar-thumb { - background: #3F3F3F; - border-radius: 6px; - } + ${сustomScrollbar} ` export const ComboboxOptionStyled = styled(ComboboxOption)` diff --git a/src/features/Common/Input/index.tsx b/src/features/Common/Input/index.tsx index 793d2bed..43671e73 100644 --- a/src/features/Common/Input/index.tsx +++ b/src/features/Common/Input/index.tsx @@ -22,6 +22,7 @@ type Props = { maxLength?: number, onBlur?: (event: FocusEvent) => void, onChange?: (event: ChangeEvent) => void, + onFocus?: (event: FocusEvent) => void, pattern?: string, required?: boolean, title?: string, @@ -40,6 +41,7 @@ export const Input = ({ maxLength, onBlur, onChange, + onFocus, paddingX, pattern, required, @@ -72,6 +74,7 @@ export const Input = ({ defaultValue={defaultValue} onChange={onChange} onBlur={onBlur} + onFocus={onFocus} maxLength={maxLength} inputWidth={inputWidth} pattern={pattern} diff --git a/src/features/Common/index.tsx b/src/features/Common/index.tsx index 7161a69c..623e4fbf 100644 --- a/src/features/Common/index.tsx +++ b/src/features/Common/index.tsx @@ -3,3 +3,4 @@ export * from './Button' export * from './Radio' export * from './Checkbox' export * from './Arrows' +export * from './сustomScrollbar' diff --git a/src/features/Common/сustomScrollbar/index.tsx b/src/features/Common/сustomScrollbar/index.tsx new file mode 100644 index 00000000..2669561c --- /dev/null +++ b/src/features/Common/сustomScrollbar/index.tsx @@ -0,0 +1,22 @@ +import { css } from 'styled-components/macro' + +export const сustomScrollbar = css` + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-thumb { + border-radius: 6px; + background: #3F3F3F; + } + + ::-webkit-scrollbar-button { + display: none; + } + + ::-webkit-scrollbar-track, + ::-webkit-scrollbar-corner { + background: transparent; + } +` diff --git a/src/features/ItemsList/hooks.tsx b/src/features/ItemsList/hooks.tsx new file mode 100644 index 00000000..fabf915e --- /dev/null +++ b/src/features/ItemsList/hooks.tsx @@ -0,0 +1,62 @@ +import { + SyntheticEvent, + useEffect, + useRef, + useCallback, +} from 'react' + +import forEach from 'lodash/forEach' + +export const useItemsList = () => { + const ref = useRef(null) + + const onError = useCallback((fallbackImage: string) => (e: SyntheticEvent) => { + // eslint-disable-next-line no-param-reassign + e.currentTarget.src = fallbackImage + }, []) + + useEffect(() => { + if (ref.current) { + /** + * Реализуем ленивую загрузку изображений + * для оптимизации запросов к серверу + */ + + // устанавливаем настройки + const options = { + // область просмотра + root: ref.current.parentElement, + // процент пересечения с областью просмотра - половина изображения + threshold: 0.5, + } + + // создаем наблюдатель + const intersectionObserver = new IntersectionObserver((entries, observer) => { + // для каждой записи-целевого элемента + forEach(entries, (entry) => { + // если элемент является наблюдаемым + if (entry.isIntersecting) { + const imgElem = entry.target as HTMLImageElement + // устанавливаем аттрибут src из data-src + imgElem.src = imgElem.dataset.src! + + // прекращаем наблюдение + observer.unobserve(imgElem) + } + }) + }, options) + + const imgElems = ref.current.getElementsByTagName('img') + + // находим все элементы img и делаем наблюдаемыми + forEach(imgElems, (imgElem) => { + intersectionObserver.observe(imgElem) + }) + } + }, []) + + return { + onError, + ref, + } +} diff --git a/src/features/ItemsList/index.tsx b/src/features/ItemsList/index.tsx new file mode 100644 index 00000000..27913aa4 --- /dev/null +++ b/src/features/ItemsList/index.tsx @@ -0,0 +1,72 @@ +import React from 'react' + +import map from 'lodash/map' + +import { SPORT_COLORS } from 'config' +import type { SportType } from 'features/Search/config' + +import { useItemsList } from './hooks' +import { + Logo, + LogoWrapper, + Item, + ItemInfo, + Name, + SportName, + StyledLink, + TeamOrCountry, + Wrapper, +} from './styled' + +type SearchItemsListProps = { + list: Array<{ + fallbackImage: string, + id: number, + logo: string, + name: string, + profileUrl: string, + sportType: SportType, + teamOrCountry?: string, + }>, +} + +export const ItemsList = ({ list }: SearchItemsListProps) => { + const { + onError, + ref, + } = useItemsList() + + return ( + + {map(list, ({ + fallbackImage, + id, + logo, + name, + profileUrl, + sportType, + teamOrCountry, + }) => ( + + + + + + + {name} + + {teamOrCountry} + + + + ))} + + ) +} diff --git a/src/features/ItemsList/styled.tsx b/src/features/ItemsList/styled.tsx new file mode 100644 index 00000000..6fded63e --- /dev/null +++ b/src/features/ItemsList/styled.tsx @@ -0,0 +1,67 @@ +import { Link } from 'react-router-dom' + +import styled from 'styled-components/macro' + +import { T9n } from 'features/T9n' + +export const Wrapper = styled.ul` + margin: 0; + padding: 0; + list-style-type: none; +` + +export const Item = styled.li` + :focus { + background-color: #999; + } +` + +export const StyledLink = styled(Link)` + display: flex; + align-items: center; + width: 100%; + height: 56px; + background-color: #666; + + :focus-within, + :hover { + background-color: #999; + outline: none; + } +` + +export const ItemInfo = styled.div` + line-height: 17px; +` + +export const Name = styled.div` + font-size: 16px; + font-weight: bold; + color: #ccc; +` + +export const SportName = styled(T9n)<{ color: string }>` + margin-right: 10px; + font-size: 11px; + color: ${({ color }) => color}; + letter-spacing: 0.02em; + text-transform: uppercase; +` + +export const TeamOrCountry = styled.span` + font-size: 11px; + color: #ccc; +` + +export const LogoWrapper = styled.div` + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + width: 55px; +` + +export const Logo = styled.img` + width: 24px; + height: 24px; +` diff --git a/src/features/Search/components/Header/index.tsx b/src/features/Search/components/Header/index.tsx new file mode 100644 index 00000000..d8c37c17 --- /dev/null +++ b/src/features/Search/components/Header/index.tsx @@ -0,0 +1,21 @@ +import React from 'react' + +import { T9n } from 'features/T9n' + +import { + Wrapper, + Icon, + titleStyles, +} from './styled' + +type HeaderProps = { + image: string, + title: string, +} + +export const Header = ({ image, title }: HeaderProps) => ( + + + + +) diff --git a/src/features/Search/components/Header/styled.tsx b/src/features/Search/components/Header/styled.tsx new file mode 100644 index 00000000..9fc3ecf3 --- /dev/null +++ b/src/features/Search/components/Header/styled.tsx @@ -0,0 +1,24 @@ +import styled, { css } from 'styled-components/macro' + +export const Wrapper = styled.div` + display: flex; + align-items: center; + height: 30px; + padding-left: 20px; + background-color: #005EDD; +` + +export const Icon = styled.div<{ image: string }>` + width: 20px; + height: 20px; + margin-right: 10px; + background-image: url(/images/${({ image }) => `${image}.svg`}); + background-size: 100%; + background-repeat: no-repeat; +` + +export const titleStyles = css` + letter-spacing: 0.02em; + text-transform: uppercase; + color: #fff; +` diff --git a/src/features/Search/config/index.tsx b/src/features/Search/config/index.tsx new file mode 100644 index 00000000..782336ae --- /dev/null +++ b/src/features/Search/config/index.tsx @@ -0,0 +1,11 @@ +export const SEARCH_DELAY = 1500 // Задержка поиска в мс + +export const MIN_CHARACTERS_LENGTH = 3 // Минимальное число символов для поиска + +export type SportType = 'football' | 'basketball' | 'hockey' + +export const SPORT_TYPES = { + 1: 'football', + 2: 'hockey', + 3: 'basketball', +} as const diff --git a/src/features/Search/hooks/index.tsx b/src/features/Search/hooks/index.tsx new file mode 100644 index 00000000..cda7504c --- /dev/null +++ b/src/features/Search/hooks/index.tsx @@ -0,0 +1,85 @@ +import type { FormEvent, ChangeEvent } from 'react' +import { + useState, + FocusEvent, + useRef, + useCallback, +} from 'react' + +import trim from 'lodash/trim' +import debounce from 'lodash/debounce' +import isEmpty from 'lodash/isEmpty' +import size from 'lodash/size' + +import type { SearchItems } from 'requests' +import { getSearchItems } from 'requests' +import { useToggle } from 'hooks' + +import { SEARCH_DELAY, MIN_CHARACTERS_LENGTH } from '../config' +import { useNormalizedItems } from './useNormalizedItems' + +export const useSearch = () => { + const [searchItems, setSearchItems] = useState({}) + const abortControllerRef = useRef(null) + const { + close, + isOpen, + open, + } = useToggle() + + const fetchSearchItems = useCallback(debounce((searchString: string) => { + const abortController = new window.AbortController() + abortControllerRef.current = abortController + + getSearchItems(searchString, abortController.signal).then((data) => { + setSearchItems(data) + abortControllerRef.current = null + }) + }, SEARCH_DELAY), []) + + const cancelRequest = useCallback(() => { + const abortController = abortControllerRef.current + + if (abortController) { + abortController.abort() + abortControllerRef.current = null + } + }, []) + + const onChange = useCallback(({ target: { value } }: ChangeEvent) => { + const trimmedValue = trim(value) + + if (size(trimmedValue) >= MIN_CHARACTERS_LENGTH) { + cancelRequest() + setSearchItems({}) + fetchSearchItems(trimmedValue) + open() + } else { + close() + } + }, [ + cancelRequest, + close, + fetchSearchItems, + open, + ]) + + const onFocus = useCallback(({ target: { value } }: FocusEvent) => { + if (size(value) >= MIN_CHARACTERS_LENGTH) { + open() + } + }, [open]) + + const onSubmit = useCallback((e: FormEvent) => { + e.preventDefault() + }, []) + + return { + close, + normalizedItems: useNormalizedItems(searchItems), + onChange, + onFocus, + onSubmit, + showResults: isOpen && !isEmpty(searchItems), + } +} diff --git a/src/features/Search/hooks/useNormalizedItems.tsx b/src/features/Search/hooks/useNormalizedItems.tsx new file mode 100644 index 00000000..313db36f --- /dev/null +++ b/src/features/Search/hooks/useNormalizedItems.tsx @@ -0,0 +1,104 @@ +import map from 'lodash/map' + +import type { SearchItems } from 'requests' +import { LOGOS_FALLBACKS } from 'config' +import { getLogo } from 'helpers' +import { useLexicsStore } from 'features/LexicsStore' + +import { SPORT_TYPES } from '../config' + +type Firstname = 'firstname_eng' | 'firstname_rus' +type Lastname = 'lastname_eng' | 'lastname_rus' +type Name = 'name_eng' | 'name_rus' + +export const useNormalizedItems = (searchItems: SearchItems) => { + const { + suffix, + } = useLexicsStore() + + const players = map(searchItems.players, (player) => { + const type = 'players' + + const firstName = player[`firstname_${suffix}` as Firstname] + const lastName = player[`lastname_${suffix}` as Lastname] + const teamName = player.team?.[`name_${suffix}` as Name] + + const sportType = SPORT_TYPES[player.sport] + + const { id } = player + + const logo = getLogo({ + id, + sportType, + type, + }) + + return { + fallbackImage: LOGOS_FALLBACKS[sportType].players, + id, + logo, + name: `${firstName} ${lastName}`, + profileUrl: `/players/${id}`, + sportType, + teamOrCountry: teamName, + } + }) + + const teams = map(searchItems.teams, (team) => { + const name = team[`name_${suffix}` as Name] + + const sportType = SPORT_TYPES[team.sport] + + const { id } = team + + const logo = getLogo({ + id, + sportType, + type: 'teams', + }) + + const country = team.country?.[`name_${suffix}` as Name] + + return { + fallbackImage: LOGOS_FALLBACKS[sportType].teams, + id, + logo, + name, + profileUrl: `/teams/${id}`, + sportType, + teamOrCountry: country, + } + }) + + const tournaments = map(searchItems.tournaments, (tournament) => { + const name = tournament[`name_${suffix}` as Name] + + const sportType = SPORT_TYPES[tournament.sport] + + const { id } = tournament + + const logo = getLogo({ + id, + sportType, + type: 'tournaments', + }) + + const country = tournament.country?.[`name_${suffix}` as Name] + + return { + fallbackImage: LOGOS_FALLBACKS[sportType].tournaments, + id, + logo, + name, + profileUrl: `/tournaments/${id}`, + sportType, + teamOrCountry: country, + } + }) + + return { + players, + teams, + tournaments, + } +} diff --git a/src/features/Search/index.tsx b/src/features/Search/index.tsx index 692b8bc3..caabb9f1 100644 --- a/src/features/Search/index.tsx +++ b/src/features/Search/index.tsx @@ -1,5 +1,69 @@ -import React from 'react' +import React, { Fragment } from 'react' -import { Wrapper } from './styled' +import isEmpty from 'lodash/isEmpty' -export const Search = () => +import { Input } from 'features/Common' +import { ItemsList } from 'features/ItemsList' +import { OutsideClick } from 'features/OutsideClick' + +import { useSearch } from './hooks' +import { Header } from './components/Header' +import { + Wrapper, + Form, + Results, +} from './styled' + +export const Search = () => { + const { + close, + normalizedItems: { + players, + teams, + tournaments, + }, + onChange, + onFocus, + onSubmit, + showResults, + } = useSearch() + + return ( + + +
+ +
+ {showResults && ( + + {!isEmpty(tournaments) && ( + +
+ + + )} + + {!isEmpty(teams) && ( + +
+ + + )} + + {!isEmpty(players) && ( + +
+ + + )} + + )} + + + ) +} diff --git a/src/features/Search/styled.tsx b/src/features/Search/styled.tsx index 8646cd0a..90fc2fe3 100644 --- a/src/features/Search/styled.tsx +++ b/src/features/Search/styled.tsx @@ -1,3 +1,64 @@ import styled from 'styled-components/macro' -export const Wrapper = styled.div`` +import { сustomScrollbar } from 'features/Common' +import { + InputWrapper, + InputStyled, + Label, +} from 'features/Common/Input/styled' + +export const Wrapper = styled.div` + position: relative; +` + +export const Form = styled.form` + ${InputWrapper} { + margin: 0; + padding-bottom: 13px; + } + + ${InputStyled} { + width: 100%; + + ::-webkit-search-decoration, + ::-webkit-search-cancel-button, + ::-webkit-search-results-button, + ::-webkit-search-results-decoration { + display: none; + } + } + + ${Label} { + ::before { + content: ''; + display: block; + width: 25px; + height: 25px; + background-image: url(/images/search.svg); + background-repeat: no-repeat; + } + } + + :focus-within { + ${InputWrapper} { + padding-left: 0; + } + + ${Label} { + ::before { + display: none; + } + } + } + +` + +export const Results = styled.div` + position: absolute; + top: 56px; + width: 448px; + max-height: 431px; + overflow-y: auto; + + ${сustomScrollbar} +` diff --git a/src/features/T9n/index.tsx b/src/features/T9n/index.tsx index 8346c52d..dc8f9231 100644 --- a/src/features/T9n/index.tsx +++ b/src/features/T9n/index.tsx @@ -9,22 +9,25 @@ const Text = styled.span` ${({ customStyles }) => customStyles} ` -type TT9n = { +type T9nProps = { + className?: string, onClick?: () => void, t: string | number, } & TCustomStyles export const T9n = ({ + className, customStyles, onClick, t, -}: TT9n) => { +}: T9nProps) => { const { translate } = useLexicsStore() return ( {translate(String(t))} diff --git a/src/helpers/getLogo/__tests__/index.tsx b/src/helpers/getLogo/__tests__/index.tsx new file mode 100644 index 00000000..b96aa7f9 --- /dev/null +++ b/src/helpers/getLogo/__tests__/index.tsx @@ -0,0 +1,23 @@ +import { getLogo } from '..' + +describe('getLogo helper', () => { + it('returns logo url', () => { + expect(getLogo({ + id: 1, + sportType: 'football', + type: 'players', + })).toBe('https://instatscout.com/images/players/180/1.png') + + expect(getLogo({ + id: 1, + sportType: 'basketball', + type: 'teams', + })).toBe('https://basketball.instatscout.com/images/teams/180/1.png') + + expect(getLogo({ + id: 1, + sportType: 'hockey', + type: 'tournaments', + })).toBe('https://hockey.instatscout.com/images/tournaments/180/1.png') + }) +}) diff --git a/src/helpers/getLogo/index.tsx b/src/helpers/getLogo/index.tsx new file mode 100644 index 00000000..dc46ee32 --- /dev/null +++ b/src/helpers/getLogo/index.tsx @@ -0,0 +1,23 @@ +import type { SportType } from 'features/Search/config' + +const IMAGES_URLS = { + basketball: 'https://basketball.instatscout.com/images', + football: 'https://instatscout.com/images', + hockey: 'https://hockey.instatscout.com/images', +} + +type GetLogoArgs = { + id: number, + size?: number, + sportType: SportType, + type: 'players' | 'teams' | 'tournaments', +} + +export const getLogo = ({ + id, + size = 180, + sportType, + type, +}: GetLogoArgs) => ( + `${IMAGES_URLS[sportType]}/${type}/${size}/${id}.png` +) diff --git a/src/helpers/index.tsx b/src/helpers/index.tsx index 1c31f046..f955ad23 100644 --- a/src/helpers/index.tsx +++ b/src/helpers/index.tsx @@ -1,3 +1,4 @@ export * from './callApi' export * from './callApi/getResponseData' export * from './token' +export * from './getLogo' diff --git a/src/requests/getSearchItems.tsx b/src/requests/getSearchItems.tsx new file mode 100644 index 00000000..89f19091 --- /dev/null +++ b/src/requests/getSearchItems.tsx @@ -0,0 +1,75 @@ +import { DATA_URL, PROCEDURES } from 'config' +import { callApi, getResponseData } from 'helpers' + +const proc = PROCEDURES.get_players_teams_tournaments + +type Gender = 1 | 2 + +type Sport = 1 | 2 | 3 + +type Player = { + firstname_eng: string, + firstname_rus: string, + gender?: Gender, + id: number, + lastname_eng: string, + lastname_rus: string, + sport: Sport, + team?: { + id: number, + name_eng: string, + name_rus: string, + }, +} + +type Team = { + country?: { + id: number, + name_eng: string, + name_rus: string, + }, + gender?: Gender, + id: number, + name_eng: string, + name_rus: string, + sport: Sport, +} + +type Tournament = { + country?: { + id: number, + name_eng: string, + name_rus: string, + }, + gender?: Gender, + id: number, + name_eng: string, + name_rus: string, + sport: Sport, +} + +export type SearchItems = { + players?: Array, + teams?: Array, + tournaments?: Array, +} + +export const getSearchItems = ( + searchString: string, + abortSignal: AbortSignal, +): Promise => { + const config = { + body: { + params: { + _p_name: searchString, + }, + proc, + }, + } + + return callApi({ + abortSignal, + config, + url: DATA_URL, + }).then(getResponseData(proc)) +} diff --git a/src/requests/index.tsx b/src/requests/index.tsx index 54087053..a877c19e 100644 --- a/src/requests/index.tsx +++ b/src/requests/index.tsx @@ -3,3 +3,4 @@ export * from './login' export * from './getCountries' export * from './getCountryCities' export * from './getLexics' +export * from './getSearchItems'