diff --git a/locales/en.json b/locales/en.json index 516be8e..9d9bace 100644 --- a/locales/en.json +++ b/locales/en.json @@ -24,6 +24,7 @@ "dry-wash.order-create.form.car-body-field.label": "Car body type", "dry-wash.order-create.form.washing-datetime-field.label": "What time is the car available?", "dry-wash.order-create.form.washing-location-field.label": "Where is the car located?", + "dry-wash.order-create.form.washing-location-field.placeholder": "Enter the address or select on the map", "dry-wash.order-create.form.washing-location-field.help": "For example, 55.754364, 48.743295 Universitetskaya Street, 1, Innopolis, Verkhneuslonsky district, Republic of Tatarstan (Tatarstan), 420500", "dry-wash.order-create.car-body-select.placeholder": "Not specified", "dry-wash.order-create.car-body-select.options.sedan": "Sedan", diff --git a/locales/ru.json b/locales/ru.json index 65ea24d..06954ac 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -54,6 +54,7 @@ "dry-wash.order-create.form.car-body-field.label": "Тип кузова автомобиля", "dry-wash.order-create.form.washing-datetime-field.label": "В какое время автомобиль доступен?", "dry-wash.order-create.form.washing-location-field.label": "Где находится автомобиль?", + "dry-wash.order-create.form.washing-location-field.placeholder": "Введите адрес или выберите на карте", "dry-wash.order-create.form.washing-location-field.help": "Например, 55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500", "dry-wash.order-create.car-body-select.placeholder": "Не указан", "dry-wash.order-create.car-body-select.options.sedan": "Седан", diff --git a/package-lock.json b/package-lock.json index 80008b6..8cc7793 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "@emotion/styled": "^11.3.0", "@fontsource/open-sans": "^5.1.0", "@lottiefiles/react-lottie-player": "^3.5.4", + "@pbe/react-yandex-maps": "^1.2.5", "@types/react": "^18.3.12", "dayjs": "^1.11.13", "express": "^4.21.1", @@ -3594,6 +3595,21 @@ "node": ">= 8" } }, + "node_modules/@pbe/react-yandex-maps": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@pbe/react-yandex-maps/-/react-yandex-maps-1.2.5.tgz", + "integrity": "sha512-cBojin5e1fPx9XVCAqHQJsCnHGMeBNsP0TrNfpWCrPFfxb30ye+JgcGr2mn767Gbr1d+RufBLRiUcX2kaiAwjQ==", + "license": "MIT", + "dependencies": { + "@types/yandex-maps": "2.1.29" + }, + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": "^16.x || ^17.x || ^18.x" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -3774,6 +3790,12 @@ "@types/react": "*" } }, + "node_modules/@types/yandex-maps": { + "version": "2.1.29", + "resolved": "https://registry.npmjs.org/@types/yandex-maps/-/yandex-maps-2.1.29.tgz", + "integrity": "sha512-nuibRWj3RU/9KXlCzTrRtDE+n6V9l7NbT9JakicqZ5OXIdwyb6blvV2Uwn6lB58WYm3DSUDP2I2AWlnWMc8z2w==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.12.2", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz", diff --git a/package.json b/package.json index 9a99014..69431e3 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@emotion/styled": "^11.3.0", "@fontsource/open-sans": "^5.1.0", "@lottiefiles/react-lottie-player": "^3.5.4", + "@pbe/react-yandex-maps": "^1.2.5", "@types/react": "^18.3.12", "dayjs": "^1.11.13", "express": "^4.21.1", diff --git a/src/components/order-form/form/location/helper.ts b/src/components/order-form/form/location/helper.ts new file mode 100644 index 0000000..a7db1ae --- /dev/null +++ b/src/components/order-form/form/location/helper.ts @@ -0,0 +1,15 @@ +import { Location, StringCoordinates, StringLocation } from "./types"; + +export const splitLocation = (stringifiedLocation: StringLocation) => { + return stringifiedLocation.replace(' ', '~').split('~'); +}; + +export const stringifyLocation = (location: Location): StringLocation => { + const { coordinates, address } = location; + const [latitude, longitude] = coordinates; + const coordinatesString = `${latitude},${longitude}` satisfies StringCoordinates; + return `${coordinatesString} ${address}`; +}; + +const locationRe = new RegExp(/^[-0-9]+.[0-9]+,[-0-9]+.[0-9]+ .*/); +export const isValidLocation = (value: string): value is StringLocation => locationRe.test(value); \ No newline at end of file diff --git a/src/components/order-form/form/location/index.ts b/src/components/order-form/form/location/index.ts new file mode 100644 index 0000000..8deeaa3 --- /dev/null +++ b/src/components/order-form/form/location/index.ts @@ -0,0 +1,4 @@ +export type { StringLocation } from './types'; +export * from './location-input'; +export * from './map'; +export * from './ymaps'; \ No newline at end of file diff --git a/src/components/order-form/form/location/location-input/helper.ts b/src/components/order-form/form/location/location-input/helper.ts new file mode 100644 index 0000000..08d259d --- /dev/null +++ b/src/components/order-form/form/location/location-input/helper.ts @@ -0,0 +1,40 @@ +import { YMapsApi } from "@pbe/react-yandex-maps/typings/util/typing"; + +import { Address, Location, StringLocation } from "../types"; +import { splitLocation, stringifyLocation } from "../helper"; + +export const formatLocation = (location: Location | undefined): StringLocation | '' => { + if (!location) { + return ''; + } + return stringifyLocation(location); +}; + +const coordinatesRe = new RegExp(/^[-0-9]+.[0-9]+,[-0-9]+.[0-9]+/); +export const extractAddress = (value: string) => value.replace(coordinatesRe, '').trim(); + +export const getLocationByAddress = async (ymaps: YMapsApi, address: Address): Promise => { + try { + const result = await ymaps.geocode(address); + const firstGeoObject = result.geoObjects.get( + 0, + ) as unknown as ymaps.IGeoObject; + const [latitude, longitude] = firstGeoObject.geometry.getCoordinates(); + return { coordinates: [latitude, longitude], address }; + } catch (error) { + console.error(error); + } +}; + +export const isRealLocation = async (ymaps: YMapsApi, stringifiedLocation: StringLocation) => { + const [coordinates, address] = splitLocation(stringifiedLocation); + try { + const result = await ymaps.geocode(coordinates); + const firstGeoObject = result.geoObjects.get(0) as ymaps.GeocodeResult; + const addressByCoordinates = firstGeoObject.getAddressLine(); + + return address === addressByCoordinates; + } catch (error) { + console.error(error); + } +}; \ No newline at end of file diff --git a/src/components/order-form/form/location/location-input/index.ts b/src/components/order-form/form/location/location-input/index.ts new file mode 100644 index 0000000..47c1b4e --- /dev/null +++ b/src/components/order-form/form/location/location-input/index.ts @@ -0,0 +1 @@ +export { LocationInput } from './location-input'; \ No newline at end of file diff --git a/src/components/order-form/form/location/location-input/location-input.tsx b/src/components/order-form/form/location/location-input/location-input.tsx new file mode 100644 index 0000000..dac3d86 --- /dev/null +++ b/src/components/order-form/form/location/location-input/location-input.tsx @@ -0,0 +1,144 @@ +import React, { forwardRef, memo, useEffect, useState } from 'react'; +import { + Input, + Box, + InputProps, + PopoverAnchor, + Popover, + PopoverContent, + PopoverBody, + List, + ListItem, +} from '@chakra-ui/react'; +import { withYMaps } from '@pbe/react-yandex-maps'; +import { useTranslation } from 'react-i18next'; + +import { Suggestion } from '../types'; +import { isValidLocation } from '../helper'; + +import { + formatLocation, + getLocationByAddress, + isRealLocation, + extractAddress, +} from './helper'; +import { LocationInputProps } from './types'; + +export const LocationInput = memo( + withYMaps( + forwardRef(function LocationInput( + { ymaps, value, onChange, ...props }, + ref, + ) { + const [inputValue, setInputValue] = useState(''); + + useEffect(() => { + setInputValue(value); + }, [value]); + + const [suggestions, setSuggestions] = useState([]); + const [isSuggestionsPanelOpen, setIsSuggestionsPanelOpen] = + useState(false); + + const onInputChange: InputProps['onChange'] = async (e) => { + const newInputValue = e.target.value; + setInputValue(newInputValue); + + if (newInputValue.trim().length > 3) { + try { + const address = extractAddress(newInputValue); + const results = await ymaps.suggest(address); + setSuggestions(results); + } catch (error) { + console.error(error); + } + } else { + setSuggestions([]); + } + + setIsSuggestionsPanelOpen(suggestions.length > 1); + }; + + const onFocus: InputProps['onFocus'] = () => { + setIsSuggestionsPanelOpen(suggestions.length > 1); + }; + + const onBlur: InputProps['onBlur'] = async (e) => { + const inputValue = e.target.value; + if ( + isValidLocation(inputValue) && + (await isRealLocation(ymaps, inputValue)) + ) { + onChange(inputValue); + } else { + setInputValue(value); + } + setIsSuggestionsPanelOpen(false); + }; + + const handleSuggestionClick = async ({ value: address }: Suggestion) => { + try { + const location = await getLocationByAddress(ymaps, address); + const newValue = formatLocation(location); + setInputValue(newValue); + onChange(newValue); + } catch (error) { + console.error(error); + } + }; + + const { t } = useTranslation('~', { + keyPrefix: 'dry-wash.order-create.form.washing-location-field', + }); + + return ( + + + + + + + + + {suggestions.map((suggestion, index) => ( + handleSuggestionClick(suggestion)} + > + {suggestion.displayName} + + ))} + + + + + + ); + }), + true, + ['suggest', 'geocode'], + ), +); + +// todo: i18n +// todo: replace console.error with toast diff --git a/src/components/order-form/form/location/location-input/types.ts b/src/components/order-form/form/location/location-input/types.ts new file mode 100644 index 0000000..8d0280a --- /dev/null +++ b/src/components/order-form/form/location/location-input/types.ts @@ -0,0 +1,8 @@ +import { YMapsApi } from "@pbe/react-yandex-maps/typings/util/typing"; +import { InputProps } from "@chakra-ui/react"; + +export type LocationInputProps = InputProps & { + ymaps?: YMapsApi; + value?: string; + onChange?: (value: string) => void; +}; \ No newline at end of file diff --git a/src/components/order-form/form/location/map/helper.ts b/src/components/order-form/form/location/map/helper.ts new file mode 100644 index 0000000..2364a6d --- /dev/null +++ b/src/components/order-form/form/location/map/helper.ts @@ -0,0 +1,10 @@ +import { splitLocation } from "../helper"; +import { Coordinate, Location, StringLocation } from "../types"; + +export const KAZAN_CITY_CENTER: Coordinate[] = [55.797557, 49.107295]; + +export const parseLocation = (stringifiedLocation: StringLocation): Location => { + const [coordinatesStr, address] = splitLocation(stringifiedLocation); + const [latitude, longitude] = coordinatesStr.split(',').map(Number); + return { coordinates: [latitude, longitude], address }; +}; \ No newline at end of file diff --git a/src/components/order-form/form/location/map/index.ts b/src/components/order-form/form/location/map/index.ts new file mode 100644 index 0000000..aa908b6 --- /dev/null +++ b/src/components/order-form/form/location/map/index.ts @@ -0,0 +1 @@ +export { MapComponent } from './map'; \ No newline at end of file diff --git a/src/components/order-form/form/location/map/map.tsx b/src/components/order-form/form/location/map/map.tsx new file mode 100644 index 0000000..05a801f --- /dev/null +++ b/src/components/order-form/form/location/map/map.tsx @@ -0,0 +1,70 @@ +import React, { FC, memo, useEffect, useState } from 'react'; +import { + Map, + Placemark, + GeolocationControl, + ZoomControl, + withYMaps, +} from '@pbe/react-yandex-maps'; +import { YMapsApi } from '@pbe/react-yandex-maps/typings/util/typing'; + +import { Coordinate, StringLocation } from '../types'; +import { isValidLocation, stringifyLocation } from '../helper'; + +import { KAZAN_CITY_CENTER, parseLocation } from './helper'; + +export const MapComponent: FC<{ + ymaps?: YMapsApi; + selectedLocation: StringLocation | null; + onMapClick: (props: StringLocation) => void; +}> = memo( + withYMaps( + ({ ymaps, selectedLocation, onMapClick }) => { + const [mapCenter, setMapCenter] = + useState(KAZAN_CITY_CENTER); + + useEffect(() => { + if (isValidLocation(selectedLocation)) { + const location = parseLocation(selectedLocation); + const { coordinates } = location; + setMapCenter(coordinates); + } + }, [selectedLocation]); + + return ( + { + try { + const coordinates = e.get('coords'); + const address = await ymaps.geocode(coordinates).then((res) => { + const firstGeoObject = res.geoObjects.get( + 0, + ) as ymaps.GeocodeResult; + return firstGeoObject.getAddressLine(); + }); + onMapClick(stringifyLocation({ coordinates, address })); + setMapCenter(coordinates); + } catch (error) { + console.error(error); + } + }} + > + + + {selectedLocation && } + + ); + }, + true, + ['geocode'], + ), +); diff --git a/src/components/order-form/form/location/types.ts b/src/components/order-form/form/location/types.ts new file mode 100644 index 0000000..677759d --- /dev/null +++ b/src/components/order-form/form/location/types.ts @@ -0,0 +1,17 @@ +export type Address = string; + +export type Suggestion = { + value: Address; + displayName: string; +}; + +export type Coordinate = number; + +export type Location = { + address: Address; + coordinates: [Coordinate, Coordinate]; +}; + +export type StringCoordinates = `${Coordinate},${Coordinate}`; + +export type StringLocation = `${StringCoordinates} ${Address}`; \ No newline at end of file diff --git a/src/components/order-form/form/location/ymaps/index.ts b/src/components/order-form/form/location/ymaps/index.ts new file mode 100644 index 0000000..8501246 --- /dev/null +++ b/src/components/order-form/form/location/ymaps/index.ts @@ -0,0 +1 @@ +export { YMapsProvider } from './ymaps-provider'; \ No newline at end of file diff --git a/src/components/order-form/form/location/ymaps/ymaps-provider.tsx b/src/components/order-form/form/location/ymaps/ymaps-provider.tsx new file mode 100644 index 0000000..88824e2 --- /dev/null +++ b/src/components/order-form/form/location/ymaps/ymaps-provider.tsx @@ -0,0 +1,16 @@ +import { YMaps } from '@pbe/react-yandex-maps'; +import React, { PropsWithChildren } from 'react'; + +export const YMapsProvider = ({ children }: PropsWithChildren) => { + return ( + + {children} + + ); +}; diff --git a/src/components/order-form/form/order-form.tsx b/src/components/order-form/form/order-form.tsx index ebe890a..b368af6 100644 --- a/src/components/order-form/form/order-form.tsx +++ b/src/components/order-form/form/order-form.tsx @@ -1,14 +1,7 @@ import React, { FC } from 'react'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { - Box, - Flex, - FormControl, - FormLabel, - Input, - VStack, -} from '@chakra-ui/react'; +import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react'; import { CarBodySelect } from './car-body'; import { CarColorInput } from './car-color'; @@ -19,6 +12,7 @@ import { PhoneInput } from './phone'; import { SubmitButton } from './submit'; import { defaultValues, onSubmit, useGetValidationRules } from './helper'; import { DateTimeInput } from './date-time'; +import { LocationInput, MapComponent, StringLocation, YMapsProvider } from './location'; export const OrderForm: FC = () => { const { @@ -26,6 +20,7 @@ export const OrderForm: FC = () => { control, formState: { errors, isSubmitting }, watch, + setValue, } = useForm({ defaultValues, shouldFocusError: true }); const { t } = useTranslation('~', { @@ -34,9 +29,10 @@ export const OrderForm: FC = () => { const RULES = useGetValidationRules(); - const [availableDatetimeBegin, availableDatetimeEnd] = watch([ + const [availableDatetimeBegin, availableDatetimeEnd, carLocation] = watch([ 'availableDatetimeBegin', 'availableDatetimeEnd', + 'carLocation', ]); return ( @@ -81,15 +77,6 @@ export const OrderForm: FC = () => { errors={errors} Input={CarBodySelect} /> - {t('washing-datetime-field.label')} @@ -119,6 +106,23 @@ export const OrderForm: FC = () => { + + + { + setValue('carLocation', location); + }} + /> + @@ -126,5 +130,4 @@ export const OrderForm: FC = () => { }; // todo: remove layout shift, when a validation message is displayed -// todo: select location using an interactive map -// todo: fix time range available values \ No newline at end of file +// todo: fix time range available values