From 7e01233e5252ff8cf3bb2ec988c7a11048c088fc Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Mon, 13 Aug 2018 10:49:01 +0300 Subject: [PATCH 1/6] Add helper to get user's location --- src/util/maps.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) 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); + }); From f1172c2503bbc2219d6bd3dbabb599d025bb48d5 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Mon, 13 Aug 2018 10:52:24 +0300 Subject: [PATCH 2/6] Add helper to calculate a bounding box --- src/util/googleMaps.js | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/util/googleMaps.js b/src/util/googleMaps.js index 2be8a364..3153b197 100644 --- a/src/util/googleMaps.js +++ b/src/util/googleMaps.js @@ -152,3 +152,25 @@ export const hasSameSDKBounds = (sdkBounds1, sdkBounds2) => { 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())); +}; From b03081767c856680465583e02b1809db334a7b7a Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Mon, 13 Aug 2018 11:05:23 +0300 Subject: [PATCH 3/6] Implement user's current location as a default location search --- CHANGELOG.md | 3 + docs/google-maps.md | 4 ++ .../GeocoderGoogleMaps.js | 23 +++++- .../GeocoderMapbox.js | 2 + .../IconCurrentLocation.js | 20 ++++++ .../IconHourGlass.js | 28 ++++++++ .../LocationAutocompleteInput.css | 13 ++++ .../LocationAutocompleteInputImpl.js | 72 +++++++++++-------- src/translations/en.json | 3 +- 9 files changed, 137 insertions(+), 31 deletions(-) create mode 100644 src/components/LocationAutocompleteInput/IconCurrentLocation.js create mode 100644 src/components/LocationAutocompleteInput/IconHourGlass.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e14557..3891503f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,9 @@ way to update this template, but currently, we follow a pattern: --- ## Upcoming version 2018-08-XX +* [change] 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) * [change] 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 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..2ab4c015 100644 --- a/src/components/LocationAutocompleteInput/GeocoderMapbox.js +++ b/src/components/LocationAutocompleteInput/GeocoderMapbox.js @@ -3,6 +3,8 @@ import config from '../../config'; const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes; +export const CURRENT_LOCATION_ID = 'current-location'; + const placeId = prediction => prediction.id; const placeAddress = prediction => prediction.place_name; 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 ? ( + + ) : ( + + )}
    Date: Mon, 13 Aug 2018 11:34:55 +0300 Subject: [PATCH 4/6] Update snapshots --- .../SectionHero/__snapshots__/SectionHero.test.js.snap | 1 + .../TopbarDesktop/__snapshots__/TopbarDesktop.test.js.snap | 1 + 2 files changed, 2 insertions(+) diff --git a/src/components/SectionHero/__snapshots__/SectionHero.test.js.snap b/src/components/SectionHero/__snapshots__/SectionHero.test.js.snap index 3e408391..12910039 100644 --- a/src/components/SectionHero/__snapshots__/SectionHero.test.js.snap +++ b/src/components/SectionHero/__snapshots__/SectionHero.test.js.snap @@ -89,6 +89,7 @@ exports[`SectionHero matches snapshot 1`] = ` autoComplete="off" autoFocus={false} className="" + disabled={false} name="location" onBlur={[Function]} onChange={[Function]} diff --git a/src/components/TopbarDesktop/__snapshots__/TopbarDesktop.test.js.snap b/src/components/TopbarDesktop/__snapshots__/TopbarDesktop.test.js.snap index f0a18c92..5017c738 100644 --- a/src/components/TopbarDesktop/__snapshots__/TopbarDesktop.test.js.snap +++ b/src/components/TopbarDesktop/__snapshots__/TopbarDesktop.test.js.snap @@ -58,6 +58,7 @@ exports[`TopbarDesktop data matches snapshot 1`] = ` autoComplete="off" autoFocus={false} className="" + disabled={false} name="location" onBlur={[Function]} onChange={[Function]} From 4092563d9ccf73b35d4ac723e536e17789285097 Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Mon, 13 Aug 2018 13:02:12 +0300 Subject: [PATCH 5/6] Implement current location default search for the Mapbox geocoder --- .../GeocoderMapbox.js | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/components/LocationAutocompleteInput/GeocoderMapbox.js b/src/components/LocationAutocompleteInput/GeocoderMapbox.js index 2ab4c015..70c0ea47 100644 --- a/src/components/LocationAutocompleteInput/GeocoderMapbox.js +++ b/src/components/LocationAutocompleteInput/GeocoderMapbox.js @@ -1,9 +1,19 @@ 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; @@ -48,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', @@ -120,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), From 8f1f64880e49c98670d459fe73ca5f85978febff Mon Sep 17 00:00:00 2001 From: Kimmo Puputti Date: Tue, 14 Aug 2018 09:35:06 +0300 Subject: [PATCH 6/6] Use "add" instead of "change" in Changelog --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3891503f..057ad202 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,10 +13,10 @@ way to update this template, but currently, we follow a pattern: --- ## Upcoming version 2018-08-XX -* [change] Add support for user's current location as a default +* [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) -* [change] Add support for default locations in the +* [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