all repos — caroster @ 78fdd8ec9cfe0e4c667c49f8e4f538231a7ea319

[Octree] Group carpool to your event https://caroster.io

feat: ✨ Use mapbox's searchbox API to geocode addresses
Simon Mulquin simon@octree.ch
Mon, 27 May 2024 10:03:58 +0000
commit

78fdd8ec9cfe0e4c667c49f8e4f538231a7ea319

parent

30926add195984283c3d9ae7431ebcf05c719182

M frontend/containers/PlaceInput/index.tsxfrontend/containers/PlaceInput/index.tsx

@@ -1,12 +1,16 @@

import {useState} from 'react'; import TextField, {TextFieldProps} from '@mui/material/TextField'; import InputAdornment from '@mui/material/InputAdornment'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined'; import Autocomplete from '@mui/material/Autocomplete'; import {debounce} from '@mui/material/utils'; +import {SessionToken} from '@mapbox/search-js-core'; import {useTranslation} from 'react-i18next'; import useLocale from '../../hooks/useLocale'; -import getPlacesSuggestions from '../../lib/getPlacesSuggestion'; +import {MapboxSuggestion} from '../../pages/api/mapbox/searchbox/suggest'; +import {GeocodedOption} from '../../pages/api/mapbox/searchbox/retrieve'; interface Props { place: string;

@@ -26,12 +30,14 @@ textFieldProps?: TextFieldProps;

disabled?: boolean; } +type Option = MapboxSuggestion | {name: String; previous?: Boolean}; + const MAPBOX_CONFIGURED = process.env['MAPBOX_CONFIGURED'] || false; const PlaceInput = ({ - place = '', latitude, longitude, + place = '', onSelect, label, textFieldProps,

@@ -41,29 +47,43 @@ const {t} = useTranslation();

const {locale} = useLocale(); const [mapboxAvailable, setMapboxAvailable] = useState(MAPBOX_CONFIGURED); const [noCoordinates, setNoCoordinates] = useState(!latitude || !longitude); - const previousOption = place ? {place_name: place, previous: true} : null; + const previousOption = place ? {name: place, previous: true} : null; + const sessionToken = new SessionToken(); - const [options, setOptions] = useState([] as Array<any>); + const [options, setOptions] = useState([] as Array<Option>); + + const getOptionDecorators = option => { + if (option.mapbox_id) { + return {secondary: option.address || option.place_formatted}; + } else { + return { + secondary: t`placeInput.item.noCoordinates`, + color: 'warning.main', + }; + } + }; + const onChange = async (e, selectedOption) => { - if (selectedOption.previous) { - setNoCoordinates(!latitude || !longitude); + if (selectedOption.mapbox_id) { + const geocodedFeature: GeocodedOption = await fetch( + '/api/mapbox/searchbox/retrieve?' + + new URLSearchParams({ + id: selectedOption.mapbox_id, + sessionToken: String(sessionToken), + locale, + }) + ).then(response => response.json()); + const {longitude, latitude} = geocodedFeature.coordinates; + setNoCoordinates(false); onSelect({ - place, + place: geocodedFeature.name, latitude, longitude, }); - } else if (selectedOption.center) { - const [optionLongitude, optionLatitude] = selectedOption.center; - setNoCoordinates(false); - onSelect({ - place: selectedOption.place_name, - latitude: optionLatitude, - longitude: optionLongitude, - }); } else { setNoCoordinates(true); onSelect({ - place: selectedOption.place_name, + place: selectedOption.name, latitude: null, longitude: null, });

@@ -72,33 +92,21 @@ };

const updateOptions = debounce(async (e, search: string) => { if (search !== '') { - getPlacesSuggestions({search, proximity: 'ip', locale}).then( - suggestions => { - let defaultOptions = []; - if (previousOption) { - defaultOptions = [previousOption]; - } - if (search && search !== previousOption?.place_name) { - defaultOptions = [...defaultOptions, {place_name: search}]; - } - if (suggestions?.length >= 1) { - setMapboxAvailable(true); - const suggestionsWithoutCopies = suggestions.filter( - ({place_name}) => - place_name !== search && - place_name !== previousOption?.place_name - ); - const uniqueOptions = [ - ...defaultOptions, - ...suggestionsWithoutCopies, - ]; - setOptions(uniqueOptions); - } else { - setMapboxAvailable(false); - setOptions(defaultOptions); - } - } - ); + try { + await fetch( + '/api/mapbox/searchbox/suggest?' + + new URLSearchParams({ + search, + sessionToken, + locale, + }) + ) + .then(response => response.json()) + .then(suggestions => setOptions([{name: search}, ...suggestions])); + } catch (err) { + console.warn(err); + setMapboxAvailable(false); + } } }, 400);

@@ -119,10 +127,10 @@ return (

<Autocomplete freeSolo disableClearable - getOptionLabel={option => option.place_name} + getOptionLabel={option => option?.name || place} options={options} - autoComplete defaultValue={previousOption} + autoComplete filterOptions={x => x} noOptionsText={t('autocomplete.noMatch')} onChange={onChange}

@@ -136,6 +144,7 @@ maxRows={4}

helperText={MAPBOX_CONFIGURED && getHelperText()} FormHelperTextProps={{sx: {color: 'warning.main'}}} InputProps={{ + type: 'search', endAdornment: ( <InputAdornment position="end" sx={{mr: -0.5}}> <PlaceOutlinedIcon />

@@ -148,7 +157,17 @@ onBlur={handleBlur}

/> )} renderOption={(props, option) => { - return <li {...props}>{option.place_name}</li>; + const {color, secondary} = getOptionDecorators(option); + if (option.previous) return null; + return ( + <ListItem key={option.mapbox_id || 'text'} {...props}> + <ListItemText + primary={option.name} + secondary={secondary} + secondaryTypographyProps={{color}} + /> + </ListItem> + ); }} /> );
M frontend/locales/en.jsonfrontend/locales/en.json

@@ -175,6 +175,7 @@ "passenger.success.goToTravels": "Go to trips",

"passenger.title": "Waiting list", "placeInput.mapboxUnavailable": "We are not able to suggest geolocated places at the moment", "placeInput.noCoordinates": "This place is not geo-located and won't appear on the map", + "placeInput.item.noCoordinates": "No coordinates", "profile.actions.cancel": "Cancel", "profile.actions.change_password": "Change your password", "profile.actions.edit": "Edit",
M frontend/locales/fr.jsonfrontend/locales/fr.json

@@ -175,6 +175,7 @@ "passenger.success.goToTravels": "Aller aux trajets",

"passenger.title": "Liste d'attente", "placeInput.mapboxUnavailable": "Nous ne pouvons pas suggérer de lieux géolocalisés pour le moment", "placeInput.noCoordinates": "Le lieu indiqué n'a pas pu être géo-localisé et ne sera pas affiché sur la carte, essayez une adresse plus précise.", + "placeInput.item.noCoordinates": "Pas de coordonnées", "profile.actions.cancel": "Annuler", "profile.actions.change_password": "Changer son mot de passe", "profile.actions.edit": "Editer",
D frontend/pages/api/mapbox/places.ts

@@ -1,33 +0,0 @@

-import type {NextApiRequest, NextApiResponse} from 'next'; - -type ResponseData = Array<{place_name: string; center: [number, number]}>; - -const {MAPBOX_TOKEN, MAPBOX_URL} = process.env; - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse<ResponseData> -) { - const {search, proximity = 'ip', locale} = req.query; - if (!search) return res.status(400).send(null); - else if (!MAPBOX_TOKEN) return res.status(500).send(null); - - const url = `${MAPBOX_URL}geocoding/v5/mapbox.places/${search}.json?proximity=${proximity}&access_token=${MAPBOX_TOKEN}&language=${locale}`; - - const mapBoxResult = await fetch(url) - .then(response => { - if (response.status === 429) { - throw 'MAPBOX_RATE_LIMIT_EXCEEDED'; - } - return response.json(); - }) - .catch(error => { - console.error(error); - }); - - if (mapBoxResult?.features) { - res.status(200).send(mapBoxResult.features); - return; - } - res.status(500).send(null); -}
A frontend/pages/api/mapbox/searchbox/retrieve.ts

@@ -0,0 +1,33 @@

+import type {NextApiRequest, NextApiResponse} from 'next'; + +export type GeocodedOption = {name: string, coordinates: {latitude, longitude}}; + +const {MAPBOX_TOKEN, MAPBOX_URL} = process.env; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<GeocodedOption> +) { + const {id, sessionToken = 'ip', locale} = req.query; + if (!id) return res.status(400).send(null); + else if (!MAPBOX_TOKEN) return res.status(500).send(null); + + const url = `${MAPBOX_URL}search/searchbox/v1/retrieve/${id}?language=${locale}&access_token=${MAPBOX_TOKEN}&session_token=${sessionToken}`; + + const mapBoxResult = await fetch(url) + .then(response => { + if (response.status === 429) { + throw 'MAPBOX_RATE_LIMIT_EXCEEDED'; + } + return response.json(); + }) + .catch(error => { + console.error(error); + }); + + if (mapBoxResult?.features?.length > 0) { + res.status(200).send(mapBoxResult.features[0].properties); + return; + } + res.status(500).send(null); +}
A frontend/pages/api/mapbox/searchbox/suggest.ts

@@ -0,0 +1,34 @@

+import type {NextApiRequest, NextApiResponse} from 'next'; + +export type MapboxSuggestion = {name: string; mapbox_id: string, address: string; place_formatted: string}; + +const {MAPBOX_TOKEN, MAPBOX_URL} = process.env; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse<Array<MapboxSuggestion>> +) { + const {search, proximity = 'ip', sessionToken, locale} = req.query; + if (!search) return res.status(400).send(null); + else if (!MAPBOX_TOKEN) return res.status(500).send(null); + + const url = `${MAPBOX_URL}search/searchbox/v1/suggest?q=${search}&proximity=${proximity}&language=${locale}&access_token=${MAPBOX_TOKEN}&session_token=${sessionToken}`; + + const mapBoxResult = await fetch(url) + .then(response => { + if (response.status === 429) { + throw 'MAPBOX_RATE_LIMIT_EXCEEDED'; + } + return response.json(); + }) + .catch(error => { + console.error(error); + }); + + if (mapBoxResult?.suggestions) { + console.log(url) + res.status(200).send(mapBoxResult.suggestions); + return; + } + res.send([]); +}