diff --git a/CHANGELOG.md b/CHANGELOG.md index b5733b8a..dcf6d7c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,10 @@ way to update this template, but currently, we follow a pattern: [#868](https://github.com/sharetribe/flex-template-web/pull/868), [#873](https://github.com/sharetribe/flex-template-web/pull/873), [#891](https://github.com/sharetribe/flex-template-web/pull/891) & [#892](https://github.com/sharetribe/flex-template-web/pull/892) -* [change] Add support for default locations in the +* [add] Add support for user's current location as a default + suggestion in the location autocomplete search. + [#895](https://github.com/sharetribe/flex-template-web/pull/895) +* [add] Add support for default locations in the LocationAutocompleteInput component. Common searches can be configured to show when the input has focus. This reduces typing and Google Places geolocation API usage. The defaults can be configured diff --git a/docs/google-maps.md b/docs/google-maps.md index 4fb336cc..4455a47e 100644 --- a/docs/google-maps.md +++ b/docs/google-maps.md @@ -36,3 +36,7 @@ need to use the Google Places geolocation API that much. The default locations can be configured in [src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js](../src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js). + +You can also setup a default location that asks the user's current +location. This will enable e.g. searching listings near the user. The +current location is configured in the same file. diff --git a/src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js b/src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js index d4bba93d..fa645d9e 100644 --- a/src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js +++ b/src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js @@ -1,8 +1,11 @@ import React from 'react'; -import { getPlacePredictions, getPlaceDetails } from '../../util/googleMaps'; - +import { getPlacePredictions, getPlaceDetails, locationBounds } from '../../util/googleMaps'; +import { userLocation } from '../../util/maps'; import css from './LocationAutocompleteInput.css'; +export const CURRENT_LOCATION_ID = 'current-location'; +const CURRENT_LOCATION_BOUNDS_DISTANCE = 1000; // meters + // A list of default predictions that can be shown when the user // focuses on the autocomplete input without typing a search. This can // be used to reduce typing and Geocoding API calls for common @@ -21,6 +24,12 @@ import css from './LocationAutocompleteInput.css'; // object from the Places API call and copy the Place ID from the // response. export const defaultPredictions = [ + // // Current user location + // { + // place_id: CURRENT_LOCATION_ID, + // // LocationAutocompleteInputImpl adds the text from the translations + // description: '', + // }, // { // place_id: 'ChIJkQYhlscLkkYRY_fiO4S9Ts0', // description: 'Helsinki, Finland', @@ -90,6 +99,16 @@ class GeocoderGoogleMaps { * @return {Promise} a place object */ getPlaceDetails(prediction) { + if (this.getPredictionId(prediction) === CURRENT_LOCATION_ID) { + return userLocation().then(latlng => { + return { + address: '', + origin: latlng, + bounds: locationBounds(latlng, CURRENT_LOCATION_BOUNDS_DISTANCE), + }; + }); + } + return getPlaceDetails(prediction.place_id, this.getSessionToken()).then(place => { this.sessionToken = null; return place; diff --git a/src/components/LocationAutocompleteInput/GeocoderMapbox.js b/src/components/LocationAutocompleteInput/GeocoderMapbox.js index 471fa18a..70c0ea47 100644 --- a/src/components/LocationAutocompleteInput/GeocoderMapbox.js +++ b/src/components/LocationAutocompleteInput/GeocoderMapbox.js @@ -1,8 +1,20 @@ import { types as sdkTypes } from '../../util/sdkLoader'; +import { userLocation } from '../../util/maps'; import config from '../../config'; const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes; +export const CURRENT_LOCATION_ID = 'current-location'; +const CURRENT_LOCATION_BOUNDS_DISTANCE = 1000; // meters + +const locationBounds = (latlng, distance) => { + const bounds = new window.mapboxgl.LngLat(latlng.lng, latlng.lat).toBounds(distance); + return new SDKLatLngBounds( + new SDKLatLng(bounds.getNorth(), bounds.getEast()), + new SDKLatLng(bounds.getSouth(), bounds.getWest()) + ); +}; + const placeId = prediction => prediction.id; const placeAddress = prediction => prediction.place_name; @@ -46,6 +58,12 @@ const placeBounds = prediction => { // object from the Geocoding API call and check the values from the // response. export const defaultPredictions = [ + // // Current user location + // { + // id: CURRENT_LOCATION_ID, + // // LocationAutocompleteInputImpl adds the text from the translations + // place_name: '', + // }, // { // id: 'default-helsinki', // place_name: 'Helsinki, Finland', @@ -118,6 +136,16 @@ class GeocoderMapbox { * @return {Promise} a place object */ getPlaceDetails(prediction) { + if (this.getPredictionId(prediction) === CURRENT_LOCATION_ID) { + return userLocation().then(latlng => { + return { + address: '', + origin: latlng, + bounds: locationBounds(latlng, CURRENT_LOCATION_BOUNDS_DISTANCE), + }; + }); + } + return Promise.resolve({ address: placeAddress(prediction), origin: placeOrigin(prediction), diff --git a/src/components/LocationAutocompleteInput/IconCurrentLocation.js b/src/components/LocationAutocompleteInput/IconCurrentLocation.js new file mode 100644 index 00000000..369042f3 --- /dev/null +++ b/src/components/LocationAutocompleteInput/IconCurrentLocation.js @@ -0,0 +1,20 @@ +import React from 'react'; + +import css from './LocationAutocompleteInput.css'; + +const IconCurrentLocation = () => ( + + + +); + +export default IconCurrentLocation; diff --git a/src/components/LocationAutocompleteInput/IconHourGlass.js b/src/components/LocationAutocompleteInput/IconHourGlass.js new file mode 100644 index 00000000..367aa56f --- /dev/null +++ b/src/components/LocationAutocompleteInput/IconHourGlass.js @@ -0,0 +1,28 @@ +import React from 'react'; + +import css from './LocationAutocompleteInput.css'; + +const IconHourGlass = () => ( + + + + + + +); + +export default IconHourGlass; diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css index 8cb14421..86285862 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInput.css @@ -41,6 +41,11 @@ } } +.iconSpinner { + margin: auto; + width: 23px; +} + .input { flex-grow: 1; height: var(--LocationAutocompleteInput_inputHeight); @@ -128,3 +133,11 @@ bottom of the container. } } } + +.currentLocation { + @apply --marketplaceH4FontStyles; +} + +.currentLocationIcon { + margin-right: 10px; +} diff --git a/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js b/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js index 816be92e..89e3a882 100644 --- a/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js +++ b/src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js @@ -1,10 +1,18 @@ import React, { Component } from 'react'; import { any, arrayOf, bool, func, number, shape, string, oneOfType, object } from 'prop-types'; +import { FormattedMessage } from 'react-intl'; import classNames from 'classnames'; import debounce from 'lodash/debounce'; +import { IconSpinner } from '../../components'; import { propTypes } from '../../util/types'; -import Geocoder, { GeocoderAttribution, defaultPredictions } from './GeocoderGoogleMaps'; -// import Geocoder, { GeocoderAttribution, defaultPredictions } from './GeocoderMapbox'; +import IconHourGlass from './IconHourGlass'; +import IconCurrentLocation from './IconCurrentLocation'; +import Geocoder, { + GeocoderAttribution, + defaultPredictions, + CURRENT_LOCATION_ID, +} from './GeocoderGoogleMaps'; +// import Geocoder, { GeocoderAttribution, defaultPredictions, CURRENT_LOCATION_ID } from './GeocoderMapbox'; import css from './LocationAutocompleteInput.css'; @@ -18,29 +26,6 @@ const DIRECTION_UP = 'up'; const DIRECTION_DOWN = 'down'; const TOUCH_TAP_RADIUS = 5; // Movement within 5px from touch start is considered a tap -const Icon = () => ( - - - - - - -); - // Touch devices need to be able to distinguish touches for scrolling and touches to tap const getTouchCoordinates = nativeEvent => { const touch = nativeEvent && nativeEvent.changedTouches ? nativeEvent.changedTouches[0] : null; @@ -66,18 +51,26 @@ const LocationPredictionsList = props => { /* eslint-disable jsx-a11y/no-static-element-interactions */ const item = (prediction, index) => { const isHighlighted = index === highlightedIndex; + const predictionId = geocoder.getPredictionId(prediction); return (
  • onSelectStart(getTouchCoordinates(e.nativeEvent))} onMouseDown={() => onSelectStart()} onTouchMove={e => onSelectMove(getTouchCoordinates(e.nativeEvent))} onTouchEnd={() => onSelectEnd(prediction)} onMouseUp={() => onSelectEnd(prediction)} > - {geocoder.getPredictionAddress(prediction)} + {predictionId === CURRENT_LOCATION_ID ? ( + + + + + ) : ( + geocoder.getPredictionAddress(prediction) + )}
  • ); }; @@ -137,11 +130,14 @@ class LocationAutocompleteInputImpl extends Component { constructor(props) { super(props); + this._isMounted = false; + this.state = { inputHasFocus: false, selectionInProgress: false, touchStartedFrom: null, highlightedIndex: -1, // -1 means no highlight + fetchingPlaceDetails: false, }; // Ref to the input element. @@ -166,6 +162,13 @@ class LocationAutocompleteInputImpl extends Component { this.predict = debounce(this.predict.bind(this), DEBOUNCE_WAIT_TIME, { leading: true }); } + componentDidMount() { + this._isMounted = true; + } + componentWillUnmount() { + this._isMounted = false; + } + currentPredictions() { const { search, predictions: fetchedPredictions } = currentValue(this.props); const hasFetchedPredictions = fetchedPredictions && fetchedPredictions.length > 0; @@ -259,9 +262,16 @@ class LocationAutocompleteInputImpl extends Component { selectedPlace: null, }); + this.setState({ fetchingPlaceDetails: true }); + this.geocoder .getPlaceDetails(prediction) .then(place => { + if (!this._isMounted) { + // Ignore if component already unmounted + return; + } + this.setState({ fetchingPlaceDetails: false }); this.props.input.onChange({ search: place.address, predictions: [], @@ -269,6 +279,7 @@ class LocationAutocompleteInputImpl extends Component { }); }) .catch(e => { + this.setState({ fetchingPlaceDetails: false }); // eslint-disable-next-line no-console console.error(e); this.props.input.onChange({ @@ -411,7 +422,11 @@ class LocationAutocompleteInputImpl extends Component { return (
    - + {this.state.fetchingPlaceDetails ? ( + + ) : ( + + )}
    { sdkBounds1.sw.lng === sdkBounds2.sw.lng ); }; + +/** + * Calculate a bounding box in the given location + * + * @param {latlng} center - center of the bounding box + * @param {distance} distance - distance in meters from the center to + * the sides of the bounding box + * + * @return {LatLngBounds} bounding box around the given location + * + */ +export const locationBounds = (latlng, distance) => { + const bounds = new window.google.maps.Circle({ + center: new window.google.maps.LatLng(latlng.lat, latlng.lng), + radius: distance, + }).getBounds(); + + const ne = bounds.getNorthEast(); + const sw = bounds.getSouthWest(); + + return new SDKLatLngBounds(new SDKLatLng(ne.lat(), ne.lng()), new SDKLatLng(sw.lat(), sw.lng())); +}; diff --git a/src/util/maps.js b/src/util/maps.js index 94d95ea3..d7ca2219 100644 --- a/src/util/maps.js +++ b/src/util/maps.js @@ -64,3 +64,25 @@ export const obfuscatedCoordinates = (latlng, cacheKey = null) => { ? memoizedObfuscatedCoordinatesImpl(latlng, cacheKey) : obfuscatedCoordinatesImpl(latlng); }; + +/** + * Query the user's current location from the browser API + * + * @return {Promise} user's current location + */ +export const userLocation = () => + new Promise((resolve, reject) => { + const geolocationAvailable = 'geolocation' in navigator; + + if (!geolocationAvailable) { + reject(new Error('Geolocation not available in browser')); + return; + } + + const onSuccess = position => + resolve(new LatLng(position.coords.latitude, position.coords.longitude)); + + const onError = error => reject(error); + + navigator.geolocation.getCurrentPosition(onSuccess, onError); + });