From c205528de6d23f007f261324a45a309093cfd093 Mon Sep 17 00:00:00 2001 From: Mirlan Date: Fri, 26 Jun 2020 18:05:14 +0600 Subject: [PATCH] Ott 90 step register (#16) --- .eslintrc | 1 + src/config/procedures.tsx | 2 + src/config/routes.tsx | 2 +- .../helpers/__tests__/index.tsx | 0 .../helpers/index.tsx | 0 src/features/Combobox/hooks/index.tsx | 81 ++++++++++++ .../Combobox/hooks/useKeyboardScroll.tsx | 28 ++++ src/features/Combobox/index.tsx | 72 +++++++++++ src/features/Combobox/styled.tsx | 86 +++++++++++++ src/features/Combobox/types.tsx | 21 +++ src/features/Common/Input/index.tsx | 10 +- src/features/CountrySelector/hooks.tsx | 88 ------------- src/features/CountrySelector/index.tsx | 63 --------- src/features/CountrySelector/styled.tsx | 27 ---- .../Register/Steps/Registration/index.tsx | 58 --------- src/features/Register/Steps/index.tsx | 3 - .../AdditionalSubscription/index.tsx | 0 .../AdditionalSubscription/styled.tsx | 0 .../Card => components/CardStep}/index.tsx | 10 +- .../MainSubscription/index.tsx | 2 +- .../Register/{ => components}/Price/index.tsx | 0 .../{ => components}/Price/styled.tsx | 0 .../components/RegistrationStep/config.tsx | 12 ++ .../RegistrationStep/hooks/useCities.tsx | 86 +++++++++++++ .../RegistrationStep/hooks/useCountries.tsx | 48 +++++++ .../RegistrationStep/hooks/useForm.tsx | 101 +++++++++++++++ .../components/RegistrationStep/index.tsx | 121 ++++++++++++++++++ .../SubscriptionsStep}/index.tsx | 5 +- .../SubscriptionsStep}/styled.tsx | 0 .../helpers/isValidEmail/__tests__/index.tsx | 22 ++++ .../Register/helpers/isValidEmail/index.tsx | 9 ++ .../isValidPassword/__tests__/index.tsx | 15 +++ .../helpers/isValidPassword/index.tsx | 14 ++ src/features/Register/index.tsx | 10 +- src/features/Register/styled.tsx | 10 -- src/hooks/index.tsx | 1 + src/hooks/useCurrentLang.tsx | 2 + src/requests/getCountryCities.tsx | 28 ++++ src/requests/index.tsx | 2 + src/requests/register.tsx | 72 +++++++++++ 40 files changed, 843 insertions(+), 269 deletions(-) rename src/features/{CountrySelector => Combobox}/helpers/__tests__/index.tsx (100%) rename src/features/{CountrySelector => Combobox}/helpers/index.tsx (100%) create mode 100644 src/features/Combobox/hooks/index.tsx create mode 100644 src/features/Combobox/hooks/useKeyboardScroll.tsx create mode 100644 src/features/Combobox/index.tsx create mode 100644 src/features/Combobox/styled.tsx create mode 100644 src/features/Combobox/types.tsx delete mode 100644 src/features/CountrySelector/hooks.tsx delete mode 100644 src/features/CountrySelector/index.tsx delete mode 100644 src/features/CountrySelector/styled.tsx delete mode 100644 src/features/Register/Steps/Registration/index.tsx delete mode 100644 src/features/Register/Steps/index.tsx rename src/features/Register/{ => components}/AdditionalSubscription/index.tsx (100%) rename src/features/Register/{ => components}/AdditionalSubscription/styled.tsx (100%) rename src/features/Register/{Steps/Card => components/CardStep}/index.tsx (82%) rename src/features/Register/{ => components}/MainSubscription/index.tsx (93%) rename src/features/Register/{ => components}/Price/index.tsx (100%) rename src/features/Register/{ => components}/Price/styled.tsx (100%) create mode 100644 src/features/Register/components/RegistrationStep/config.tsx create mode 100644 src/features/Register/components/RegistrationStep/hooks/useCities.tsx create mode 100644 src/features/Register/components/RegistrationStep/hooks/useCountries.tsx create mode 100644 src/features/Register/components/RegistrationStep/hooks/useForm.tsx create mode 100644 src/features/Register/components/RegistrationStep/index.tsx rename src/features/Register/{Steps/Subscriptions => components/SubscriptionsStep}/index.tsx (91%) rename src/features/Register/{Steps/Subscriptions => components/SubscriptionsStep}/styled.tsx (100%) create mode 100644 src/features/Register/helpers/isValidEmail/__tests__/index.tsx create mode 100644 src/features/Register/helpers/isValidEmail/index.tsx create mode 100644 src/features/Register/helpers/isValidPassword/__tests__/index.tsx create mode 100644 src/features/Register/helpers/isValidPassword/index.tsx create mode 100644 src/hooks/useCurrentLang.tsx create mode 100644 src/requests/getCountryCities.tsx create mode 100644 src/requests/register.tsx diff --git a/.eslintrc b/.eslintrc index a3ce36ee..32b18a22 100644 --- a/.eslintrc +++ b/.eslintrc @@ -82,6 +82,7 @@ "import/no-unresolved": "off", "import/prefer-default-export": "off", "indent": "off", + "no-underscore-dangle": "off", "no-unused-vars": "off", "react/jsx-one-expression-per-line": "off", "react/jsx-fragments": "off", diff --git a/src/config/procedures.tsx b/src/config/procedures.tsx index 6138575d..257173e6 100644 --- a/src/config/procedures.tsx +++ b/src/config/procedures.tsx @@ -1,3 +1,5 @@ export const PROCEDURES = { + create_user: 'create_user', + get_cities: 'get_cities', lst_c_country: 'lst_c_country', } diff --git a/src/config/routes.tsx b/src/config/routes.tsx index 708156ee..8d513d50 100644 --- a/src/config/routes.tsx +++ b/src/config/routes.tsx @@ -1,2 +1,2 @@ -export const API_ROOT = 'http://85.10.224.24:8080' +export const API_ROOT = 'http://api-staging.instat.tv' export const DATA_URL = `${API_ROOT}/data` diff --git a/src/features/CountrySelector/helpers/__tests__/index.tsx b/src/features/Combobox/helpers/__tests__/index.tsx similarity index 100% rename from src/features/CountrySelector/helpers/__tests__/index.tsx rename to src/features/Combobox/helpers/__tests__/index.tsx diff --git a/src/features/CountrySelector/helpers/index.tsx b/src/features/Combobox/helpers/index.tsx similarity index 100% rename from src/features/CountrySelector/helpers/index.tsx rename to src/features/Combobox/helpers/index.tsx diff --git a/src/features/Combobox/hooks/index.tsx b/src/features/Combobox/hooks/index.tsx new file mode 100644 index 00000000..80e64915 --- /dev/null +++ b/src/features/Combobox/hooks/index.tsx @@ -0,0 +1,81 @@ +import type { ChangeEvent, FocusEvent } from 'react' +import { useState, useCallback } from 'react' + +import isUndefined from 'lodash/isUndefined' +import toLower from 'lodash/toLower' +import slice from 'lodash/slice' +import find from 'lodash/find' +import trim from 'lodash/trim' + +import type { Props, Option } from '../types' +import { matchSort } from '../helpers' +import { useKeyboardScroll } from './useKeyboardScroll' + +const isOptionClicked = (target: HTMLElement) => ( + target?.getAttribute('role') === 'option' +) + +const useQuery = ({ onChange, value }: Props) => { + const [query, setQuery] = useState('') + + const onQueryChange = useCallback(({ target }: ChangeEvent) => { + setQuery(target.value) + }, []) + + return { + onQueryChange: !isUndefined(onChange) ? onChange : onQueryChange, + query: !isUndefined(value) ? value : query, + setQuery, + } +} + +export const useCombobox = (props: Props) => { + const { onSelect, options } = props + + const { + onQueryChange, + query, + setQuery, + } = useQuery(props) + + const results = matchSort( + options, + 'name', + query, + ) + + const findOptionByName = (optionName: string) => ( + find( + options, + ({ name }) => toLower(name) === toLower(trim(optionName)), + ) || null + ) + + const onOptionSelect = (option: string) => { + const selectedOption = findOptionByName(option) + setQuery(selectedOption?.name || '') + onSelect?.(selectedOption) + } + + const onInputBlur = (event: FocusEvent) => { + const target = event.relatedTarget as HTMLElement | null + // клик по элементу списка тоже вызывает onBlur + // если кликали элемент списка то событие обрабатывает onOptionSelect + if (target && isOptionClicked(target)) return + + onOptionSelect(query) + } + + return { + ...useKeyboardScroll(), + onInputBlur, + onOptionSelect, + onQueryChange, + options: slice( + results, + 0, + 20, + ), + query, + } +} diff --git a/src/features/Combobox/hooks/useKeyboardScroll.tsx b/src/features/Combobox/hooks/useKeyboardScroll.tsx new file mode 100644 index 00000000..0aee4f47 --- /dev/null +++ b/src/features/Combobox/hooks/useKeyboardScroll.tsx @@ -0,0 +1,28 @@ +import type { KeyboardEvent } from 'react' +import { useRef } from 'react' + +export const useKeyboardScroll = () => { + const popoverRef = useRef(null) + const onKeyDown = (event: KeyboardEvent) => { + const container = popoverRef.current + if (event.isDefaultPrevented() || !container) return + + window.requestAnimationFrame(() => { + const el = container.querySelector('[aria-selected=true]') + if (!el) return + + const { clientHeight, scrollTop } = container + const top = el.offsetTop - scrollTop + const bottom = (scrollTop + clientHeight) - (el.offsetTop + el.clientHeight) + + if (bottom < 0) { + container.scrollTop -= bottom + } + if (top < 0) { + container.scrollTop += top + } + }) + } + + return { onKeyDown, popoverRef } +} diff --git a/src/features/Combobox/index.tsx b/src/features/Combobox/index.tsx new file mode 100644 index 00000000..13f8693f --- /dev/null +++ b/src/features/Combobox/index.tsx @@ -0,0 +1,72 @@ +import React from 'react' + +import isEmpty from 'lodash/isEmpty' +import map from 'lodash/map' +import '@reach/combobox/styles.css' + +import { Label } from 'features/Common/Input/styled' + +import { Props, Option } from './types' +import { useCombobox } from './hooks' +import { + ComboboxStyled, + ComboboxInputStyled, + ComboboxPopoverStyled, + ComboboxListStyled, + ComboboxOptionStyled, + Arrow, +} from './styled' + +export const Combobox = (props: Props) => { + const { + disabled, + id, + label, + labelWidth, + pattern, + required, + } = props + const { + onInputBlur, + onKeyDown, + onOptionSelect, + onQueryChange, + options, + popoverRef, + query, + } = useCombobox(props) + + return ( + + + + {!isEmpty(options) && ( + + + {map(options, ({ id: optionId, name }) => ( + + ))} + + + )} + + ) +} diff --git a/src/features/Combobox/styled.tsx b/src/features/Combobox/styled.tsx new file mode 100644 index 00000000..ccf6de46 --- /dev/null +++ b/src/features/Combobox/styled.tsx @@ -0,0 +1,86 @@ +import styled from 'styled-components/macro' + +import { + Combobox, + ComboboxInput, + ComboboxList, + ComboboxOption, + ComboboxPopover, +} from '@reach/combobox' + +import { wrapperStyles, inputStyles } from 'features/Common/Input/styled' + +export const ComboboxStyled = styled(Combobox)` + ${wrapperStyles} + position: relative; +` + +export const ComboboxInputStyled = styled(ComboboxInput)` + ${inputStyles} + padding-right: 24px; +` + +export const Arrow = styled.div` + position: absolute; + right: 20px; + top: 50%; + transform: translateY(-50%); + width: 12px; + height: 12px; + background-image: url(/images/arrowDown.svg); + background-position: center; + background-repeat: no-repeat; +` + +export const ComboboxPopoverStyled = styled(ComboboxPopover)` + border: none; +` + +export const ComboboxListStyled = styled(ComboboxList)` + background: #666; + min-width: 544px; + max-height: 336px; + height: auto; + border-radius: 2px; + box-shadow: 0px 1px 1px rgba(0, 0, 0, 0.3); + position: absolute; + top: 0; + left: 0; + transform: translate(-126px, 9px); + overflow: auto; + + ::-webkit-scrollbar { + width: 8px; + } + + ::-webkit-scrollbar-track { + background: transparent; + } + + ::-webkit-scrollbar-thumb { + background: #3F3F3F; + border-radius: 6px; + } +` + +export const ComboboxOptionStyled = styled(ComboboxOption)` + width: 100%; + height: 48px; + font-size: 16px; + font-weight: bold; + display: flex; + align-items: center; + padding-left: 24px; + color: #ccc; + background: transparent; + + &[aria-selected="true"] { + background: #999; + color: #fff; + } + + &:hover { + background: #999; + color: #fff; + } +` diff --git a/src/features/Combobox/types.tsx b/src/features/Combobox/types.tsx new file mode 100644 index 00000000..4b8d5de9 --- /dev/null +++ b/src/features/Combobox/types.tsx @@ -0,0 +1,21 @@ +import type { InputHTMLAttributes, ChangeEvent } from 'react' + +export type Option = { + id: number, + name: string, +} + +export type Props = Pick, ( + | 'id' + | 'onChange' + | 'required' + | 'disabled' + | 'pattern' +)> & { + label?: string, + labelWidth?: number, + onChange?: (event: ChangeEvent) => void, + onSelect?: (option: T | null) => void, + options: Array, + value?: string, +} diff --git a/src/features/Common/Input/index.tsx b/src/features/Common/Input/index.tsx index 55bbbc6b..01879e5f 100644 --- a/src/features/Common/Input/index.tsx +++ b/src/features/Common/Input/index.tsx @@ -1,4 +1,4 @@ -import React from 'react' +import React, { ChangeEvent } from 'react' import { TInputWrapper, @@ -14,8 +14,10 @@ type TInput = { label: string, labelWidth?: number, maxLength?: number, - onChange?: () => void, + onChange?: (event: ChangeEvent) => void, + pattern?: string, required?: boolean, + title?: string, type?: string, value?: string, } & TInputWrapper @@ -29,7 +31,9 @@ export const Input = ({ maxLength, onChange, paddingX, + pattern, required, + title, type, value, wrapperWidth, @@ -53,6 +57,8 @@ export const Input = ({ onChange={onChange} maxLength={maxLength} inputWidth={inputWidth} + pattern={pattern} + title={title} /> ) diff --git a/src/features/CountrySelector/hooks.tsx b/src/features/CountrySelector/hooks.tsx deleted file mode 100644 index 61a69061..00000000 --- a/src/features/CountrySelector/hooks.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import type { ChangeEvent, FocusEvent } from 'react' -import type { Countries } from 'requests' - -import { - useEffect, - useState, - useCallback, -} from 'react' - -import toLower from 'lodash/toLower' -import slice from 'lodash/slice' -import find from 'lodash/find' - -import { getCountries } from 'requests' - -import { matchSort } from './helpers' - -// временно, будем считывать из стора(контекста) лексики -const useCurrentLang = () => 'eng' - -const useCountriesList = () => { - const [countries, setCountries] = useState([]) - - useEffect(() => { - getCountries().then(setCountries) - }, []) - - return countries -} - -const useQuery = () => { - const [query, setQuery] = useState('') - - const onQueryChange = useCallback(({ target }: ChangeEvent) => { - setQuery(target.value) - }, []) - - return { - onQueryChange, - query, - setQuery, - } -} - -const isOptionClicked = (target: HTMLElement) => target?.getAttribute('role') === 'option' - -export const useCountrySelector = () => { - const lang = useCurrentLang() - const countries = useCountriesList() - const { - onQueryChange, - query, - setQuery, - } = useQuery() - - const keyToSortBy = `name_${lang}` as 'name_eng' - - const results = matchSort( - countries, - keyToSortBy, - query, - ) - - const onBlur = (event: FocusEvent) => { - const target = event.relatedTarget as HTMLElement | null - // клик по элементу списка тоже вызывает onBlur - // если кликали элемент списка то событие обрабатывается onCountrySelect - if (target && isOptionClicked(target)) return - - const found = find( - countries, - (country) => toLower(country[keyToSortBy]) === toLower(query), - ) - setQuery(found ? found[keyToSortBy] : '') - } - - return { - countries: slice( - results, - 0, - 20, - ), - onBlur, - onCountrySelect: setQuery, - onQueryChange, - query, - } -} diff --git a/src/features/CountrySelector/index.tsx b/src/features/CountrySelector/index.tsx deleted file mode 100644 index 07912710..00000000 --- a/src/features/CountrySelector/index.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React from 'react' - -import isEmpty from 'lodash/isEmpty' -import map from 'lodash/map' -import { - ComboboxPopover, - ComboboxList, - ComboboxOption, -} from '@reach/combobox' -import '@reach/combobox/styles.css' - -import { Label } from 'features/Common/Input/styled' - -import { useCountrySelector } from './hooks' -import { - ComboboxStyled, - ComboboxInputStyled, - Arrow, -} from './styled' - -type Props = { - labelWidth?: number, -} - -export const CountrySelector = ({ labelWidth }: Props) => { - const { - countries, - onBlur, - onCountrySelect, - onQueryChange, - query, - } = useCountrySelector() - - return ( - - - - {!isEmpty(countries) && ( - - - {map(countries, ({ id, name_eng }) => ( - - ))} - - - )} - - ) -} diff --git a/src/features/CountrySelector/styled.tsx b/src/features/CountrySelector/styled.tsx deleted file mode 100644 index bcd76ac8..00000000 --- a/src/features/CountrySelector/styled.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import styled from 'styled-components/macro' - -import { Combobox, ComboboxInput } from '@reach/combobox' - -import { wrapperStyles, inputStyles } from 'features/Common/Input/styled' - -export const ComboboxStyled = styled(Combobox)` - ${wrapperStyles} - position: relative; -` - -export const ComboboxInputStyled = styled(ComboboxInput)` - ${inputStyles} - padding-right: 24px; -` - -export const Arrow = styled.div` - position: absolute; - right: 20px; - top: 50%; - transform: translateY(-50%); - width: 12px; - height: 12px; - background-image: url(/images/arrowDown.svg); - background-position: center; - background-repeat: no-repeat; -` diff --git a/src/features/Register/Steps/Registration/index.tsx b/src/features/Register/Steps/Registration/index.tsx deleted file mode 100644 index 3eefccde..00000000 --- a/src/features/Register/Steps/Registration/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import React from 'react' - -import { PAGES } from 'config' - -import { CountrySelector } from 'features/CountrySelector' -import { Input } from 'features/Common' -import { - BlockTitle, - ButtonsBlock, - Form, -} from 'features/Login/styled' - -import { NextButton } from '../../styled' - -const labelWidth = 78 - -export const Registration = () => ( -
- Регистрация - - - - - - - - - - - Далее - - -) diff --git a/src/features/Register/Steps/index.tsx b/src/features/Register/Steps/index.tsx deleted file mode 100644 index ceb3df99..00000000 --- a/src/features/Register/Steps/index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export * from './Registration' -export * from './Card' -export * from './Subscriptions' diff --git a/src/features/Register/AdditionalSubscription/index.tsx b/src/features/Register/components/AdditionalSubscription/index.tsx similarity index 100% rename from src/features/Register/AdditionalSubscription/index.tsx rename to src/features/Register/components/AdditionalSubscription/index.tsx diff --git a/src/features/Register/AdditionalSubscription/styled.tsx b/src/features/Register/components/AdditionalSubscription/styled.tsx similarity index 100% rename from src/features/Register/AdditionalSubscription/styled.tsx rename to src/features/Register/components/AdditionalSubscription/styled.tsx diff --git a/src/features/Register/Steps/Card/index.tsx b/src/features/Register/components/CardStep/index.tsx similarity index 82% rename from src/features/Register/Steps/Card/index.tsx rename to src/features/Register/components/CardStep/index.tsx index 3da20959..ec8cc083 100644 --- a/src/features/Register/Steps/Card/index.tsx +++ b/src/features/Register/components/CardStep/index.tsx @@ -1,17 +1,13 @@ import React from 'react' -import { Input } from 'features/Common' +import { Input, ButtonSolid } from 'features/Common' import { BlockTitle, ButtonsBlock, Form, } from 'features/Login/styled' -import { - NextButton, - Card, - Row, -} from '../../styled' +import { Card, Row } from '../../styled' export const CardStep = () => (
@@ -42,7 +38,7 @@ export const CardStep = () => ( - Далее + Далее
) diff --git a/src/features/Register/MainSubscription/index.tsx b/src/features/Register/components/MainSubscription/index.tsx similarity index 93% rename from src/features/Register/MainSubscription/index.tsx rename to src/features/Register/components/MainSubscription/index.tsx index 9685a56f..9cd9f593 100644 --- a/src/features/Register/MainSubscription/index.tsx +++ b/src/features/Register/components/MainSubscription/index.tsx @@ -7,7 +7,7 @@ import { SubscriptionWrapper, SubscriptionTitle, Row, -} from '../Steps/Subscriptions/styled' +} from '../SubscriptionsStep/styled' type TMainSubscription = { price: number, diff --git a/src/features/Register/Price/index.tsx b/src/features/Register/components/Price/index.tsx similarity index 100% rename from src/features/Register/Price/index.tsx rename to src/features/Register/components/Price/index.tsx diff --git a/src/features/Register/Price/styled.tsx b/src/features/Register/components/Price/styled.tsx similarity index 100% rename from src/features/Register/Price/styled.tsx rename to src/features/Register/components/Price/styled.tsx diff --git a/src/features/Register/components/RegistrationStep/config.tsx b/src/features/Register/components/RegistrationStep/config.tsx new file mode 100644 index 00000000..104eeef1 --- /dev/null +++ b/src/features/Register/components/RegistrationStep/config.tsx @@ -0,0 +1,12 @@ +export const formIds = { + address1: 'address1', + address2: 'address2', + city: 'city', + country: 'country', + email: 'email', + firstname: 'firstname', + lastname: 'lastname', + password: 'password', + phone: 'phone', + postalCode: 'postalCode', +} diff --git a/src/features/Register/components/RegistrationStep/hooks/useCities.tsx b/src/features/Register/components/RegistrationStep/hooks/useCities.tsx new file mode 100644 index 00000000..1bd29576 --- /dev/null +++ b/src/features/Register/components/RegistrationStep/hooks/useCities.tsx @@ -0,0 +1,86 @@ +import type { ChangeEvent } from 'react' +import { + useState, + useCallback, + useEffect, +} from 'react' + +import debounce from 'lodash/debounce' +import trim from 'lodash/trim' + +import type { Cities, City } from 'requests' +import { getCountryCities } from 'requests' + +const useCitiesList = () => { + const [cities, setCities] = useState([]) + + const getCities = (city: string, selectedCountryId: number) => { + getCountryCities(city, selectedCountryId).then(setCities) + } + + const getCitiesDebounced = useCallback( + debounce(getCities, 300), + [], + ) + + const resetCities = useCallback(() => setCities([]), []) + + return { + cities, + getCities: getCitiesDebounced, + resetCities, + } +} + +export const useCities = (selectedCountryId?: number) => { + const [cityQuery, setCityQuery] = useState('') + const [selectedCity, setSelectedCity] = useState(null) + + const { + cities, + getCities, + resetCities, + } = useCitiesList() + + const trimmedCity = trim(cityQuery) + useEffect(() => { + if (trimmedCity && selectedCountryId) { + getCities(trimmedCity, selectedCountryId) + } + }, [ + trimmedCity, + selectedCountryId, + getCities, + ]) + + const onCityQueryChange = ({ target }: ChangeEvent) => { + setCityQuery(target.value) + } + + const onCitySelect = (newCity: City | null) => { + if (newCity) { + setCityQuery(newCity.name) + setSelectedCity(newCity) + } else { + setSelectedCity(null) + } + } + + const resetSelectedCity = useCallback( + () => { + setCityQuery('') + setSelectedCity(null) + }, + [setCityQuery, setSelectedCity], + ) + + return { + cities, + cityQuery, + onCityQueryChange, + onCitySelect, + resetCities, + resetSelectedCity, + selectedCity, + } +} diff --git a/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx b/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx new file mode 100644 index 00000000..d4c5b479 --- /dev/null +++ b/src/features/Register/components/RegistrationStep/hooks/useCountries.tsx @@ -0,0 +1,48 @@ +import { + useEffect, + useState, + useMemo, +} from 'react' + +import map from 'lodash/map' + +import type { Countries } from 'requests' +import { getCountries } from 'requests' +import { useCurrentLang } from 'hooks' + +type Country = { + id: number, + name: string, +} + +const useCountriesList = () => { + const [countries, setCountries] = useState([]) + + useEffect(() => { + getCountries().then(setCountries) + }, []) + + return countries +} + +export const useCountries = () => { + const countries = useCountriesList() + const lang = useCurrentLang() + const [selectedCountry, setSelectedCountry] = useState(null) + + const nameField = `name_${lang}` as 'name_eng' + + const transformedCountries = useMemo( + () => map(countries, (country) => ({ + id: country.id, + name: country[nameField], + })), + [countries, nameField], + ) + + return { + countries: transformedCountries, + onCountrySelect: setSelectedCountry, + selectedCountry, + } +} diff --git a/src/features/Register/components/RegistrationStep/hooks/useForm.tsx b/src/features/Register/components/RegistrationStep/hooks/useForm.tsx new file mode 100644 index 00000000..ca30fceb --- /dev/null +++ b/src/features/Register/components/RegistrationStep/hooks/useForm.tsx @@ -0,0 +1,101 @@ +import type { FormEvent } from 'react' +import { useEffect } from 'react' +import { useHistory } from 'react-router-dom' + +import trim from 'lodash/trim' + +import { PAGES } from 'config' +import { register } from 'requests' + +import { isValidEmail } from 'features/Register/helpers/isValidEmail' + +import { formIds } from '../config' +import { useCountries } from './useCountries' +import { useCities } from './useCities' + +const readFormValue = (event: FormEvent) => ( + (fieldName: string) => trim(event.currentTarget[fieldName]?.value) +) + +export const useForm = () => { + const history = useHistory() + const { + countries, + onCountrySelect, + selectedCountry, + } = useCountries() + + const { + cities, + cityQuery, + onCityQueryChange, + onCitySelect, + resetCities, + resetSelectedCity, + selectedCity, + } = useCities(selectedCountry?.id) + + useEffect(() => { + resetSelectedCity() + resetCities() + }, [ + selectedCountry, + resetSelectedCity, + resetCities, + ]) + + const goToLoginPage = () => { + history.replace(PAGES.login) + } + + const showError = (message: string) => { + // eslint-disable-next-line no-alert + window.alert(message) + } + + const getCityParams = () => { + if (selectedCity) return { cityId: selectedCity.id } + return { city: cityQuery } + } + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + if (!selectedCountry) return + + const readFieldValue = readFormValue(event) + const firstname = readFieldValue(formIds.firstname) + const lastname = readFieldValue(formIds.lastname) + const phone = readFieldValue(formIds.phone) + const email = readFieldValue(formIds.email) + const password = readFieldValue(formIds.password) + const postalCode = Number(readFieldValue(formIds.postalCode)) + const address1 = readFieldValue(formIds.address1) + const address2 = readFieldValue(formIds.address2) + + if (isValidEmail(email)) { + register({ + address1, + address2, + ...getCityParams(), + countryId: selectedCountry.id, + email, + firstname, + lastname, + password, + phone, + postalCode, + }).then(goToLoginPage, showError) + } + } + + return { + cities, + cityQuery, + countries, + handleSubmit, + onCityQueryChange, + onCitySelect, + onCountrySelect, + selectedCountry, + } +} diff --git a/src/features/Register/components/RegistrationStep/index.tsx b/src/features/Register/components/RegistrationStep/index.tsx new file mode 100644 index 00000000..9d1f6bde --- /dev/null +++ b/src/features/Register/components/RegistrationStep/index.tsx @@ -0,0 +1,121 @@ +import React from 'react' + +import { Combobox } from 'features/Combobox' +import { Input, ButtonSolid } from 'features/Common' +import { + BlockTitle, + ButtonsBlock, + Form, +} from 'features/Login/styled' + +import { formIds } from './config' +import { passwordRegex } from '../../helpers/isValidPassword' +import { emailRegex } from '../../helpers/isValidEmail' +import { useForm } from './hooks/useForm' + +const commonFieldRegex = '^.{0,500}$' +const postalCodeRegex = '^\\d{5}(?:[-\\s]\\d{4})?$' +const labelWidth = 78 + +export const RegistrationStep = () => { + const { + cities, + cityQuery, + countries, + handleSubmit, + onCityQueryChange, + onCitySelect, + onCountrySelect, + selectedCountry, + } = useForm() + + return ( +
+ Регистрация + + + + + + + + + + + + + + Далее + + + ) +} diff --git a/src/features/Register/Steps/Subscriptions/index.tsx b/src/features/Register/components/SubscriptionsStep/index.tsx similarity index 91% rename from src/features/Register/Steps/Subscriptions/index.tsx rename to src/features/Register/components/SubscriptionsStep/index.tsx index ba620420..58b3d8ea 100644 --- a/src/features/Register/Steps/Subscriptions/index.tsx +++ b/src/features/Register/components/SubscriptionsStep/index.tsx @@ -3,9 +3,8 @@ import React, { Fragment } from 'react' import { ButtonSolid } from 'features/Common/Button' import { ArrowLeft, ArrowRight } from 'features/Common/Arrows' import { ButtonsBlock } from 'features/Login/styled' - -import { MainSubscription } from 'features/Register/MainSubscription' -import { AdditionalSubscription } from 'features/Register/AdditionalSubscription' +import { MainSubscription } from 'features/Register/components/MainSubscription' +import { AdditionalSubscription } from 'features/Register/components/AdditionalSubscription' import { SubscriptionsBlock, diff --git a/src/features/Register/Steps/Subscriptions/styled.tsx b/src/features/Register/components/SubscriptionsStep/styled.tsx similarity index 100% rename from src/features/Register/Steps/Subscriptions/styled.tsx rename to src/features/Register/components/SubscriptionsStep/styled.tsx diff --git a/src/features/Register/helpers/isValidEmail/__tests__/index.tsx b/src/features/Register/helpers/isValidEmail/__tests__/index.tsx new file mode 100644 index 00000000..b0cbb44e --- /dev/null +++ b/src/features/Register/helpers/isValidEmail/__tests__/index.tsx @@ -0,0 +1,22 @@ +import { isValidEmail } from '..' + +it('invalid emails', () => { + expect(isValidEmail('a')).toBeFalsy() + expect(isValidEmail('1')).toBeFalsy() + expect(isValidEmail('a@')).toBeFalsy() + expect(isValidEmail('a@m')).toBeFalsy() + expect(isValidEmail('a@m.')).toBeFalsy() + expect(isValidEmail('a@mail')).toBeFalsy() + expect(isValidEmail('a@mail.')).toBeFalsy() + expect(isValidEmail('abcd.mail.com')).toBeFalsy() +}) + +it('valid emails', () => { + expect(isValidEmail('a@mail.com')).toBeTruthy() + expect(isValidEmail('A@MAIL.COM')).toBeTruthy() + expect(isValidEmail('123@mail.com')).toBeTruthy() + expect(isValidEmail('A123@mail.com')).toBeTruthy() + expect(isValidEmail('a.b@mail.com')).toBeTruthy() + expect(isValidEmail('a-b-c@mail.com')).toBeTruthy() + expect(isValidEmail('a-b@a-b.com')).toBeTruthy() +}) diff --git a/src/features/Register/helpers/isValidEmail/index.tsx b/src/features/Register/helpers/isValidEmail/index.tsx new file mode 100644 index 00000000..7d23de6b --- /dev/null +++ b/src/features/Register/helpers/isValidEmail/index.tsx @@ -0,0 +1,9 @@ +import size from 'lodash/size' + +export const emailRegex = '[a-z0-9!#$%&/\'*+/=?^_`{|}~-]+(?:\\.[a-z0-9!#$%&/\'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?' + +const emailRegExp = new RegExp(emailRegex) + +export const isValidEmail = (email: string) => ( + emailRegExp.test(email.toLowerCase()) && size(email) <= 100 +) diff --git a/src/features/Register/helpers/isValidPassword/__tests__/index.tsx b/src/features/Register/helpers/isValidPassword/__tests__/index.tsx new file mode 100644 index 00000000..17fb9953 --- /dev/null +++ b/src/features/Register/helpers/isValidPassword/__tests__/index.tsx @@ -0,0 +1,15 @@ +import { isValidPassword } from '..' + +it('invalid passwords', () => { + expect(isValidPassword('a')).toBeFalsy() + expect(isValidPassword('1')).toBeFalsy() + expect(isValidPassword('abcdef')).toBeFalsy() + expect(isValidPassword('abcdef@')).toBeFalsy() + expect(isValidPassword('abcdef@123')).toBeFalsy() + expect(isValidPassword('ABcd12$')).toBeFalsy() +}) + +it('valid passwords', () => { + expect(isValidPassword('aASbc!def123$')).toBeTruthy() + expect(isValidPassword('Abcdef@123$')).toBeTruthy() +}) diff --git a/src/features/Register/helpers/isValidPassword/index.tsx b/src/features/Register/helpers/isValidPassword/index.tsx new file mode 100644 index 00000000..c8cdbec4 --- /dev/null +++ b/src/features/Register/helpers/isValidPassword/index.tsx @@ -0,0 +1,14 @@ +export const passwordRegex = '^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).{8,500}$' + +const passwordRegExp = new RegExp(passwordRegex) + +/** + * At least one upper case English letter, (?=.*?[A-Z]) + * At least one lower case English letter, (?=.*?[a-z]) + * At least one digit, (?=.*?[0-9]) + * At least one special character, (?=.*?[#?!@$%^&*-]) + * Minimum eight in length .{8,} (with the anchors) + */ +export const isValidPassword = (password: string) => ( + passwordRegExp.test(password) +) diff --git a/src/features/Register/index.tsx b/src/features/Register/index.tsx index 657a8773..02a5ba4d 100644 --- a/src/features/Register/index.tsx +++ b/src/features/Register/index.tsx @@ -7,18 +7,16 @@ import { Background } from 'features/Background' import { Logo } from 'features/Logo' import { CenterBlock } from 'features/Login/styled' -import { - Registration, - CardStep, - SubscriptionStep, -} from './Steps' +import { RegistrationStep } from './components/RegistrationStep' +import { CardStep } from './components/CardStep' +import { SubscriptionStep } from './components/SubscriptionsStep' export const Register = () => ( - + diff --git a/src/features/Register/styled.tsx b/src/features/Register/styled.tsx index 12833bf6..ccee050d 100644 --- a/src/features/Register/styled.tsx +++ b/src/features/Register/styled.tsx @@ -1,15 +1,5 @@ -import { Link } from 'react-router-dom' import styled from 'styled-components/macro' -import { solidButtonStyles } from 'features/Common' - -export const NextButton = styled(Link)` - ${solidButtonStyles} - display: flex; - align-items: center; - justify-content: center; -` - export const Card = styled.div` width: 546px; height: 340px; diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index cd448150..4c227a28 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1 +1,2 @@ export * from './usePageId' +export * from './useCurrentLang' diff --git a/src/hooks/useCurrentLang.tsx b/src/hooks/useCurrentLang.tsx new file mode 100644 index 00000000..379b7d19 --- /dev/null +++ b/src/hooks/useCurrentLang.tsx @@ -0,0 +1,2 @@ +// временно, будем считывать из стора(контекста) лексики +export const useCurrentLang = () => 'eng' diff --git a/src/requests/getCountryCities.tsx b/src/requests/getCountryCities.tsx new file mode 100644 index 00000000..801d59de --- /dev/null +++ b/src/requests/getCountryCities.tsx @@ -0,0 +1,28 @@ +import { DATA_URL, PROCEDURES } from 'config' +import { callApi, getResponseData } from 'helpers' + +const proc = PROCEDURES.get_cities + +export type City = { + id: number, + name: string, +} + +export type Cities = Array + +export const getCountryCities = (query: string, countryId: number): Promise => { + const config = { + body: { + params: { + _p_country_id: countryId, + _p_name: query, + }, + proc, + }, + } + + return callApi({ + config, + url: DATA_URL, + }).then(getResponseData(proc)) +} diff --git a/src/requests/index.tsx b/src/requests/index.tsx index a803e3e4..46debb05 100644 --- a/src/requests/index.tsx +++ b/src/requests/index.tsx @@ -1,2 +1,4 @@ +export * from './register' export * from './login' export * from './getCountries' +export * from './getCountryCities' diff --git a/src/requests/register.tsx b/src/requests/register.tsx new file mode 100644 index 00000000..69fb8781 --- /dev/null +++ b/src/requests/register.tsx @@ -0,0 +1,72 @@ +import { DATA_URL, PROCEDURES } from 'config' +import { callApi } from 'helpers' + +const proc = PROCEDURES.create_user + +const responseStatus = { + FAILURE: 2, + SUCCESS: 1, +} + +type Response = { + _p_error: string | null, + _p_status: 1 | 2, +} + +type Args = { + address1: string, + address2?: string, + city?: string, + cityId?: number, + countryId: number, + email: string, + firstname: string, + lastname: string, + password: string, + phone: string, + postalCode: number, +} + +export const register = async ({ + address1, + address2, + city, + cityId, + countryId, + email, + firstname, + lastname, + password, + phone, + postalCode, +}: Args) => { + const config = { + body: { + params: { + _p_address_line1: address1, + _p_address_line2: address2, + _p_city: city, + _p_city_id: cityId, + _p_country_id: countryId, + _p_email: email, + _p_firstname: firstname, + _p_lastname: lastname, + _p_password: password, + _p_phone: phone, + _p_postal_code: postalCode, + }, + proc, + }, + } + + const response: Response = await callApi({ + config, + url: DATA_URL, + }) + + if (response._p_status === responseStatus.SUCCESS) { + return Promise.resolve(response) + } + + return Promise.reject(response._p_error) +}