mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
Merge pull request #895 from sharetribe/autocomplete-current-location
Autocomplete current location
This commit is contained in:
commit
04e85c8f47
13 changed files with 210 additions and 32 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
28
src/components/LocationAutocompleteInput/IconHourGlass.js
Normal file
28
src/components/LocationAutocompleteInput/IconHourGlass.js
Normal 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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -89,6 +89,7 @@ exports[`SectionHero matches snapshot 1`] = `
|
|||
autoComplete="off"
|
||||
autoFocus={false}
|
||||
className=""
|
||||
disabled={false}
|
||||
name="location"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ exports[`TopbarDesktop data matches snapshot 1`] = `
|
|||
autoComplete="off"
|
||||
autoFocus={false}
|
||||
className=""
|
||||
disabled={false}
|
||||
name="location"
|
||||
onBlur={[Function]}
|
||||
onChange={[Function]}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue