Ott 90 step register (#16)
parent
b548d1e607
commit
c205528de6
@ -1,3 +1,5 @@ |
|||||||
export const PROCEDURES = { |
export const PROCEDURES = { |
||||||
|
create_user: 'create_user', |
||||||
|
get_cities: 'get_cities', |
||||||
lst_c_country: 'lst_c_country', |
lst_c_country: 'lst_c_country', |
||||||
} |
} |
||||||
|
|||||||
@ -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` |
export const DATA_URL = `${API_ROOT}/data` |
||||||
|
|||||||
@ -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 = <T extends Option>({ onChange, value }: Props<T>) => { |
||||||
|
const [query, setQuery] = useState('') |
||||||
|
|
||||||
|
const onQueryChange = useCallback(({ target }: ChangeEvent<HTMLInputElement>) => { |
||||||
|
setQuery(target.value) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return { |
||||||
|
onQueryChange: !isUndefined(onChange) ? onChange : onQueryChange, |
||||||
|
query: !isUndefined(value) ? value : query, |
||||||
|
setQuery, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const useCombobox = <T extends Option>(props: Props<T>) => { |
||||||
|
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<HTMLInputElement>) => { |
||||||
|
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, |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,28 @@ |
|||||||
|
import type { KeyboardEvent } from 'react' |
||||||
|
import { useRef } from 'react' |
||||||
|
|
||||||
|
export const useKeyboardScroll = () => { |
||||||
|
const popoverRef = useRef<HTMLUListElement>(null) |
||||||
|
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => { |
||||||
|
const container = popoverRef.current |
||||||
|
if (event.isDefaultPrevented() || !container) return |
||||||
|
|
||||||
|
window.requestAnimationFrame(() => { |
||||||
|
const el = container.querySelector<HTMLElement>('[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 } |
||||||
|
} |
||||||
@ -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 = <T extends Option>(props: Props<T>) => { |
||||||
|
const { |
||||||
|
disabled, |
||||||
|
id, |
||||||
|
label, |
||||||
|
labelWidth, |
||||||
|
pattern, |
||||||
|
required, |
||||||
|
} = props |
||||||
|
const { |
||||||
|
onInputBlur, |
||||||
|
onKeyDown, |
||||||
|
onOptionSelect, |
||||||
|
onQueryChange, |
||||||
|
options, |
||||||
|
popoverRef, |
||||||
|
query, |
||||||
|
} = useCombobox(props) |
||||||
|
|
||||||
|
return ( |
||||||
|
<ComboboxStyled onSelect={onOptionSelect}> |
||||||
|
<Label |
||||||
|
labelWidth={labelWidth} |
||||||
|
htmlFor={id} |
||||||
|
> |
||||||
|
{label} |
||||||
|
<Arrow /> |
||||||
|
</Label> |
||||||
|
<ComboboxInputStyled |
||||||
|
id={id} |
||||||
|
required={required} |
||||||
|
disabled={disabled} |
||||||
|
value={query} |
||||||
|
pattern={pattern} |
||||||
|
onChange={onQueryChange} |
||||||
|
onBlur={onInputBlur} |
||||||
|
onKeyDown={onKeyDown} |
||||||
|
/> |
||||||
|
{!isEmpty(options) && ( |
||||||
|
<ComboboxPopoverStyled> |
||||||
|
<ComboboxListStyled ref={popoverRef}> |
||||||
|
{map(options, ({ id: optionId, name }) => ( |
||||||
|
<ComboboxOptionStyled |
||||||
|
key={optionId} |
||||||
|
value={name} |
||||||
|
/> |
||||||
|
))} |
||||||
|
</ComboboxListStyled> |
||||||
|
</ComboboxPopoverStyled> |
||||||
|
)} |
||||||
|
</ComboboxStyled> |
||||||
|
) |
||||||
|
} |
||||||
@ -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; |
||||||
|
} |
||||||
|
` |
||||||
@ -0,0 +1,21 @@ |
|||||||
|
import type { InputHTMLAttributes, ChangeEvent } from 'react' |
||||||
|
|
||||||
|
export type Option = { |
||||||
|
id: number, |
||||||
|
name: string, |
||||||
|
} |
||||||
|
|
||||||
|
export type Props<T> = Pick<InputHTMLAttributes<HTMLInputElement>, ( |
||||||
|
| 'id' |
||||||
|
| 'onChange' |
||||||
|
| 'required' |
||||||
|
| 'disabled' |
||||||
|
| 'pattern' |
||||||
|
)> & { |
||||||
|
label?: string, |
||||||
|
labelWidth?: number, |
||||||
|
onChange?: (event: ChangeEvent<HTMLInputElement>) => void, |
||||||
|
onSelect?: (option: T | null) => void, |
||||||
|
options: Array<T>, |
||||||
|
value?: string, |
||||||
|
} |
||||||
@ -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<Countries>([]) |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
getCountries().then(setCountries) |
|
||||||
}, []) |
|
||||||
|
|
||||||
return countries |
|
||||||
} |
|
||||||
|
|
||||||
const useQuery = () => { |
|
||||||
const [query, setQuery] = useState('') |
|
||||||
|
|
||||||
const onQueryChange = useCallback(({ target }: ChangeEvent<HTMLInputElement>) => { |
|
||||||
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<HTMLInputElement>) => { |
|
||||||
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, |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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 ( |
|
||||||
<ComboboxStyled onSelect={onCountrySelect}> |
|
||||||
<Label |
|
||||||
labelWidth={labelWidth} |
|
||||||
htmlFor='country' |
|
||||||
> |
|
||||||
Страна |
|
||||||
<Arrow /> |
|
||||||
</Label> |
|
||||||
<ComboboxInputStyled |
|
||||||
id='country' |
|
||||||
value={query} |
|
||||||
onChange={onQueryChange} |
|
||||||
onBlur={onBlur} |
|
||||||
/> |
|
||||||
{!isEmpty(countries) && ( |
|
||||||
<ComboboxPopover> |
|
||||||
<ComboboxList> |
|
||||||
{map(countries, ({ id, name_eng }) => ( |
|
||||||
<ComboboxOption |
|
||||||
key={id} |
|
||||||
value={name_eng} |
|
||||||
/> |
|
||||||
))} |
|
||||||
</ComboboxList> |
|
||||||
</ComboboxPopover> |
|
||||||
)} |
|
||||||
</ComboboxStyled> |
|
||||||
) |
|
||||||
} |
|
||||||
@ -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; |
|
||||||
` |
|
||||||
@ -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 = () => ( |
|
||||||
<Form> |
|
||||||
<BlockTitle>Регистрация</BlockTitle> |
|
||||||
|
|
||||||
<Input |
|
||||||
id='firstname' |
|
||||||
label='Имя' |
|
||||||
labelWidth={labelWidth} |
|
||||||
/> |
|
||||||
<Input |
|
||||||
id='lastname' |
|
||||||
label='Фамилия' |
|
||||||
labelWidth={labelWidth} |
|
||||||
/> |
|
||||||
<Input |
|
||||||
id='phone' |
|
||||||
label='Телефон' |
|
||||||
labelWidth={labelWidth} |
|
||||||
/> |
|
||||||
<Input |
|
||||||
id='email' |
|
||||||
type='email' |
|
||||||
label='E-mail' |
|
||||||
labelWidth={labelWidth} |
|
||||||
/> |
|
||||||
<CountrySelector labelWidth={78} /> |
|
||||||
<Input |
|
||||||
id='address1' |
|
||||||
label='Адрес 1' |
|
||||||
labelWidth={labelWidth} |
|
||||||
/> |
|
||||||
<Input |
|
||||||
id='address2' |
|
||||||
label='Адрес 2' |
|
||||||
labelWidth={labelWidth} |
|
||||||
/> |
|
||||||
|
|
||||||
<ButtonsBlock> |
|
||||||
<NextButton to={`${PAGES.register}/card`}>Далее</NextButton> |
|
||||||
</ButtonsBlock> |
|
||||||
</Form> |
|
||||||
) |
|
||||||
@ -1,3 +0,0 @@ |
|||||||
export * from './Registration' |
|
||||||
export * from './Card' |
|
||||||
export * from './Subscriptions' |
|
||||||
@ -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', |
||||||
|
} |
||||||
@ -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<Cities>([]) |
||||||
|
|
||||||
|
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<City | null>(null) |
||||||
|
|
||||||
|
const { |
||||||
|
cities, |
||||||
|
getCities, |
||||||
|
resetCities, |
||||||
|
} = useCitiesList() |
||||||
|
|
||||||
|
const trimmedCity = trim(cityQuery) |
||||||
|
useEffect(() => { |
||||||
|
if (trimmedCity && selectedCountryId) { |
||||||
|
getCities(trimmedCity, selectedCountryId) |
||||||
|
} |
||||||
|
}, [ |
||||||
|
trimmedCity, |
||||||
|
selectedCountryId, |
||||||
|
getCities, |
||||||
|
]) |
||||||
|
|
||||||
|
const onCityQueryChange = ({ target }: ChangeEvent<HTMLInputElement>) => { |
||||||
|
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, |
||||||
|
} |
||||||
|
} |
||||||
@ -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<Countries>([]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
getCountries().then(setCountries) |
||||||
|
}, []) |
||||||
|
|
||||||
|
return countries |
||||||
|
} |
||||||
|
|
||||||
|
export const useCountries = () => { |
||||||
|
const countries = useCountriesList() |
||||||
|
const lang = useCurrentLang() |
||||||
|
const [selectedCountry, setSelectedCountry] = useState<Country | null>(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, |
||||||
|
} |
||||||
|
} |
||||||
@ -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<HTMLFormElement>) => ( |
||||||
|
(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<HTMLFormElement>) => { |
||||||
|
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, |
||||||
|
} |
||||||
|
} |
||||||
@ -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 ( |
||||||
|
<Form onSubmit={handleSubmit}> |
||||||
|
<BlockTitle>Регистрация</BlockTitle> |
||||||
|
|
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.firstname} |
||||||
|
label='Имя' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern={commonFieldRegex} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.lastname} |
||||||
|
label='Фамилия' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern={commonFieldRegex} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.phone} |
||||||
|
type='tel' |
||||||
|
label='Телефон' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern='^[0-9]{8,100}$' |
||||||
|
/> |
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.email} |
||||||
|
type='email' |
||||||
|
label='E-mail' |
||||||
|
title='example@mail.com' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern={emailRegex} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.password} |
||||||
|
type='password' |
||||||
|
label='Пароль' |
||||||
|
title='Inst@1TV' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern={passwordRegex} |
||||||
|
/> |
||||||
|
<Combobox |
||||||
|
required |
||||||
|
id={formIds.country} |
||||||
|
label='Страна' |
||||||
|
labelWidth={labelWidth} |
||||||
|
options={countries} |
||||||
|
onSelect={onCountrySelect} |
||||||
|
/> |
||||||
|
<Combobox |
||||||
|
required |
||||||
|
id={formIds.city} |
||||||
|
label='Город' |
||||||
|
labelWidth={labelWidth} |
||||||
|
disabled={!selectedCountry} |
||||||
|
value={cityQuery} |
||||||
|
onChange={onCityQueryChange} |
||||||
|
options={cities} |
||||||
|
onSelect={onCitySelect} |
||||||
|
pattern={commonFieldRegex} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.postalCode} |
||||||
|
label='Почтовый адрес' |
||||||
|
pattern={postalCodeRegex} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
required |
||||||
|
id={formIds.address1} |
||||||
|
label='Адрес 1' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern={commonFieldRegex} |
||||||
|
/> |
||||||
|
<Input |
||||||
|
id={formIds.address2} |
||||||
|
label='Адрес 2' |
||||||
|
labelWidth={labelWidth} |
||||||
|
pattern={commonFieldRegex} |
||||||
|
/> |
||||||
|
|
||||||
|
<ButtonsBlock> |
||||||
|
<ButtonSolid type='submit'>Далее</ButtonSolid> |
||||||
|
</ButtonsBlock> |
||||||
|
</Form> |
||||||
|
) |
||||||
|
} |
||||||
@ -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() |
||||||
|
}) |
||||||
@ -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 |
||||||
|
) |
||||||
@ -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() |
||||||
|
}) |
||||||
@ -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) |
||||||
|
) |
||||||
@ -1 +1,2 @@ |
|||||||
export * from './usePageId' |
export * from './usePageId' |
||||||
|
export * from './useCurrentLang' |
||||||
|
|||||||
@ -0,0 +1,2 @@ |
|||||||
|
// временно, будем считывать из стора(контекста) лексики
|
||||||
|
export const useCurrentLang = () => 'eng' |
||||||
@ -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<City> |
||||||
|
|
||||||
|
export const getCountryCities = (query: string, countryId: number): Promise<Cities> => { |
||||||
|
const config = { |
||||||
|
body: { |
||||||
|
params: { |
||||||
|
_p_country_id: countryId, |
||||||
|
_p_name: query, |
||||||
|
}, |
||||||
|
proc, |
||||||
|
}, |
||||||
|
} |
||||||
|
|
||||||
|
return callApi({ |
||||||
|
config, |
||||||
|
url: DATA_URL, |
||||||
|
}).then(getResponseData(proc)) |
||||||
|
} |
||||||
@ -1,2 +1,4 @@ |
|||||||
|
export * from './register' |
||||||
export * from './login' |
export * from './login' |
||||||
export * from './getCountries' |
export * from './getCountries' |
||||||
|
export * from './getCountryCities' |
||||||
|
|||||||
@ -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) |
||||||
|
} |
||||||
Loading…
Reference in new issue