Merge pull request #895 from sharetribe/autocomplete-current-location

Autocomplete current location
This commit is contained in:
Kimmo Puputti 2018-08-14 09:49:32 +03:00 committed by GitHub
commit 04e85c8f47
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 210 additions and 32 deletions

View file

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

View file

@ -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.

View file

@ -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<util.propTypes.place>} 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;

View file

@ -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<util.propTypes.place>} 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),

View file

@ -0,0 +1,20 @@
import React from 'react';
import css from './LocationAutocompleteInput.css';
const IconCurrentLocation = () => (
<svg
className={css.currentLocationIcon}
width="12"
height="12"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.9269636.07279915c-.0779984-.0770013-.1959959-.0950016-.2924939-.04400074L.13470842 6.02889945c-.10199788.05300089-.15499678.16900284-.12749735.28100473.02799942.11150188.12799734.1900032.24299496.1900032h5.249891v5.25008842c0 .1150019.07899836.2160036.19049604.2430041C5.71009267 11.998 5.73059224 12 5.75009184 12c.0914981 0 .1779963-.0505009.22199539-.1345023L11.9719627.36530407c.0499989-.09650162.0319993-.21500362-.0449991-.29250492"
fill="#FFF"
fillRule="evenodd"
/>
</svg>
);
export default IconCurrentLocation;

View file

@ -0,0 +1,28 @@
import React from 'react';
import css from './LocationAutocompleteInput.css';
const IconHourGlass = () => (
<svg
className={css.iconSvg}
width="21"
height="22"
viewBox="0 0 21 22"
xmlns="http://www.w3.org/2000/svg"
>
<g
className={css.iconSvgGroup}
transform="matrix(-1 0 0 1 20 1)"
strokeWidth="2"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13 14l5.241 5.241" />
<circle cx="7.5" cy="7.5" r="7.5" />
</g>
</svg>
);
export default IconHourGlass;

View file

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

View file

@ -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 = () => (
<svg
className={css.iconSvg}
width="21"
height="22"
viewBox="0 0 21 22"
xmlns="http://www.w3.org/2000/svg"
>
<g
className={css.iconSvgGroup}
transform="matrix(-1 0 0 1 20 1)"
strokeWidth="2"
fill="none"
fillRule="evenodd"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M13 14l5.241 5.241" />
<circle cx="7.5" cy="7.5" r="7.5" />
</g>
</svg>
);
// 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 (
<li
className={isHighlighted ? css.highlighted : null}
key={geocoder.getPredictionId(prediction)}
key={predictionId}
onTouchStart={e => 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 ? (
<span className={css.currentLocation}>
<IconCurrentLocation />
<FormattedMessage id="LocationAutocompleteInput.currentLocation" />
</span>
) : (
geocoder.getPredictionAddress(prediction)
)}
</li>
);
};
@ -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 (
<div className={rootClass}>
<div className={iconClass}>
<Icon />
{this.state.fetchingPlaceDetails ? (
<IconSpinner className={css.iconSpinner} />
) : (
<IconHourGlass />
)}
</div>
<input
className={inputClass}
@ -421,6 +436,7 @@ class LocationAutocompleteInputImpl extends Component {
placeholder={placeholder}
name={name}
value={search}
disabled={this.state.fetchingPlaceDetails}
onFocus={handleOnFocus}
onBlur={this.handleOnBlur}
onChange={this.onChange}

View file

@ -89,6 +89,7 @@ exports[`SectionHero matches snapshot 1`] = `
autoComplete="off"
autoFocus={false}
className=""
disabled={false}
name="location"
onBlur={[Function]}
onChange={[Function]}

View file

@ -58,6 +58,7 @@ exports[`TopbarDesktop data matches snapshot 1`] = `
autoComplete="off"
autoFocus={false}
className=""
disabled={false}
name="location"
onBlur={[Function]}
onChange={[Function]}

View file

@ -89,8 +89,8 @@
"ContactDetailsForm.emailTakenError": "This email address is already in use.",
"ContactDetailsForm.emailUnverified": "You haven't verified your email address yet. {resendEmailMessage}",
"ContactDetailsForm.emailVerified": "Your email address is verified.",
"ContactDetailsForm.genericFailure": "Whoops, something went wrong. Please refresh the page and try again.",
"ContactDetailsForm.genericEmailFailure": "Whoops, something went wrong while updating your email. Please refresh the page and try again.",
"ContactDetailsForm.genericFailure": "Whoops, something went wrong. Please refresh the page and try again.",
"ContactDetailsForm.genericPhoneNumberFailure": "Whoops, something went wrong while updating your phone number. Please refresh the page and try again.",
"ContactDetailsForm.passwordFailed": "Please double-check your password",
"ContactDetailsForm.passwordLabel": "Current password",
@ -302,6 +302,7 @@
"ListingPage.schemaTitle": "{title} - {price} | {siteTitle}",
"ListingPage.viewImagesButton": "View photos ({count})",
"ListingPage.yourHostHeading": "Your host",
"LocationAutocompleteInput.currentLocation": "Current location",
"LocationSearchForm.placeholder": "Search saunas…",
"LoginForm.emailInvalid": "A valid email address is required",
"LoginForm.emailLabel": "Email",

View file

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

View file

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