feat: use react-yandex-maps for location input (#8) #50

Merged
primakov merged 1 commits from feature/order-form into main 2024-12-22 11:21:02 +03:00
17 changed files with 375 additions and 20 deletions

View File

@ -24,6 +24,7 @@
"dry-wash.order-create.form.car-body-field.label": "Car body type", "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-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.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.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.placeholder": "Not specified",
"dry-wash.order-create.car-body-select.options.sedan": "Sedan", "dry-wash.order-create.car-body-select.options.sedan": "Sedan",

View File

@ -54,6 +54,7 @@
"dry-wash.order-create.form.car-body-field.label": "Тип кузова автомобиля", "dry-wash.order-create.form.car-body-field.label": "Тип кузова автомобиля",
"dry-wash.order-create.form.washing-datetime-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.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.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.placeholder": "Не указан",
"dry-wash.order-create.car-body-select.options.sedan": "Седан", "dry-wash.order-create.car-body-select.options.sedan": "Седан",

22
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@fontsource/open-sans": "^5.1.0", "@fontsource/open-sans": "^5.1.0",
"@lottiefiles/react-lottie-player": "^3.5.4", "@lottiefiles/react-lottie-player": "^3.5.4",
"@pbe/react-yandex-maps": "^1.2.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"express": "^4.21.1", "express": "^4.21.1",
@ -3594,6 +3595,21 @@
"node": ">= 8" "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": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@ -3774,6 +3790,12 @@
"@types/react": "*" "@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": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.12.2", "version": "8.12.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz",

View File

@ -24,6 +24,7 @@
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@fontsource/open-sans": "^5.1.0", "@fontsource/open-sans": "^5.1.0",
"@lottiefiles/react-lottie-player": "^3.5.4", "@lottiefiles/react-lottie-player": "^3.5.4",
"@pbe/react-yandex-maps": "^1.2.5",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"express": "^4.21.1", "express": "^4.21.1",

View File

@ -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);

View File

@ -0,0 +1,4 @@
export type { StringLocation } from './types';
export * from './location-input';
export * from './map';
export * from './ymaps';

View File

@ -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<Location> => {
try {
const result = await ymaps.geocode(address);
const firstGeoObject = result.geoObjects.get(
0,
) as unknown as ymaps.IGeoObject<ymaps.IBasePointGeometry>;
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);
}
};

View File

@ -0,0 +1 @@
export { LocationInput } from './location-input';

View File

@ -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';
Review

import { type LocationInputProps } from './types';

import { type LocationInputProps } from './types';
export const LocationInput = memo(
withYMaps(
forwardRef<HTMLInputElement, LocationInputProps>(function LocationInput(
{ ymaps, value, onChange, ...props },
ref,
) {
const [inputValue, setInputValue] = useState<string>('');
useEffect(() => {
setInputValue(value);
}, [value]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isSuggestionsPanelOpen, setIsSuggestionsPanelOpen] =
useState<boolean>(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 (
<Box width='100%'>
<Popover
isOpen={isSuggestionsPanelOpen}
autoFocus={false}
placement='bottom-start'
>
<PopoverAnchor>
<Input
{...props}
ref={ref}
onBlur={onBlur}
value={inputValue}
onChange={onInputChange}
onFocus={onFocus}
placeholder={t('placeholder')}
/>
</PopoverAnchor>
<PopoverContent width='100%' maxWidth='100%'>
<PopoverBody border='1px' borderColor='gray.300' p={0}>
<List>
{suggestions.map((suggestion, index) => (
<ListItem
key={index}
p={2}
cursor='pointer'
_hover={{
bgColor: 'primary.50',
}}
_active={{
bgColor: 'primary.100',
}}
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.displayName}
</ListItem>
))}
</List>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
);
}),
true,
['suggest', 'geocode'],
),
);
// todo: i18n
// todo: replace console.error with toast

View File

@ -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;
};

View File

@ -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 };
};

View File

@ -0,0 +1 @@
export { MapComponent } from './map';

View File

@ -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<Coordinate[]>(KAZAN_CITY_CENTER);
useEffect(() => {
if (isValidLocation(selectedLocation)) {
const location = parseLocation(selectedLocation);
const { coordinates } = location;
setMapCenter(coordinates);
}
}, [selectedLocation]);
return (
<Map
state={{
center: mapCenter,
zoom:
isValidLocation(selectedLocation) &&
parseLocation(selectedLocation)?.coordinates
? 17
: 10,
}}
width='100%'
onClick={async (e: ymaps.MapEvent) => {
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);
}
}}
>
<GeolocationControl options={{ float: 'left' }} />
<ZoomControl />
{selectedLocation && <Placemark geometry={mapCenter} />}
</Map>
);
},
true,
['geocode'],
),
);

View File

@ -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}`;

View File

@ -0,0 +1 @@
export { YMapsProvider } from './ymaps-provider';

View File

@ -0,0 +1,16 @@
import { YMaps } from '@pbe/react-yandex-maps';
import React, { PropsWithChildren } from 'react';
export const YMapsProvider = ({ children }: PropsWithChildren) => {
return (
<YMaps
query={{
lang: 'ru_RU',
apikey: '19cbc387-db9a-4807-a620-5205215db40f',
suggest_apikey: '3576e65e-5e75-4ea2-b21c-7f9db112682e',
}}
>
{children}
</YMaps>
);
};

View File

@ -1,14 +1,7 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react';
Box,
Flex,
FormControl,
FormLabel,
Input,
VStack,
} from '@chakra-ui/react';
import { CarBodySelect } from './car-body'; import { CarBodySelect } from './car-body';
import { CarColorInput } from './car-color'; import { CarColorInput } from './car-color';
@ -19,6 +12,7 @@ import { PhoneInput } from './phone';
import { SubmitButton } from './submit'; import { SubmitButton } from './submit';
import { defaultValues, onSubmit, useGetValidationRules } from './helper'; import { defaultValues, onSubmit, useGetValidationRules } from './helper';
import { DateTimeInput } from './date-time'; import { DateTimeInput } from './date-time';
import { LocationInput, MapComponent, StringLocation, YMapsProvider } from './location';
export const OrderForm: FC = () => { export const OrderForm: FC = () => {
const { const {
@ -26,6 +20,7 @@ export const OrderForm: FC = () => {
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
watch, watch,
setValue,
} = useForm<OrderFormValues>({ defaultValues, shouldFocusError: true }); } = useForm<OrderFormValues>({ defaultValues, shouldFocusError: true });
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
@ -34,9 +29,10 @@ export const OrderForm: FC = () => {
const RULES = useGetValidationRules(); const RULES = useGetValidationRules();
const [availableDatetimeBegin, availableDatetimeEnd] = watch([ const [availableDatetimeBegin, availableDatetimeEnd, carLocation] = watch([
'availableDatetimeBegin', 'availableDatetimeBegin',
'availableDatetimeEnd', 'availableDatetimeEnd',
'carLocation',
]); ]);
return ( return (
@ -81,15 +77,6 @@ export const OrderForm: FC = () => {
errors={errors} errors={errors}
Input={CarBodySelect} Input={CarBodySelect}
/> />
<FormInputField
control={control}
name='carLocation'
label={t('washing-location-field.label')}
isRequired
errors={errors}
Input={Input}
help={t('washing-location-field.help')}
/>
<FormControl isRequired> <FormControl isRequired>
<FormLabel>{t('washing-datetime-field.label')}</FormLabel> <FormLabel>{t('washing-datetime-field.label')}</FormLabel>
<Flex flexWrap='wrap' gap={4}> <Flex flexWrap='wrap' gap={4}>
@ -119,6 +106,23 @@ export const OrderForm: FC = () => {
</Box> </Box>
</Flex> </Flex>
</FormControl> </FormControl>
<YMapsProvider>
<FormInputField
control={control}
name='carLocation'
label={t('washing-location-field.label')}
help={t('washing-location-field.help')}
isRequired
errors={errors}
Input={LocationInput}
/>
<MapComponent
selectedLocation={carLocation as StringLocation}
onMapClick={(location) => {
setValue('carLocation', location);
}}
/>
</YMapsProvider>
<SubmitButton isLoading={isSubmitting} mt={4} /> <SubmitButton isLoading={isSubmitting} mt={4} />
</VStack> </VStack>
</Box> </Box>
@ -126,5 +130,4 @@ export const OrderForm: FC = () => {
}; };
// todo: remove layout shift, when a validation message is displayed // todo: remove layout shift, when a validation message is displayed
// todo: select location using an interactive map
// todo: fix time range available values // todo: fix time range available values