mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-29 05:00:43 +10:00
Merge pull request #888 from sharetribe/init-mapbox
Init mapbox with the access token in env
This commit is contained in:
commit
5bd4873f25
35 changed files with 5539 additions and 522 deletions
|
|
@ -2,8 +2,11 @@
|
|||
# Mandatory configuration
|
||||
#
|
||||
REACT_APP_SHARETRIBE_SDK_CLIENT_ID=
|
||||
REACT_APP_GOOGLE_MAPS_API_KEY=
|
||||
REACT_APP_STRIPE_PUBLISHABLE_KEY=
|
||||
REACT_APP_MAPBOX_ACCESS_TOKEN=
|
||||
|
||||
# Or set up an alternative map provider (Google Maps). Check documentation.
|
||||
# REACT_APP_GOOGLE_MAPS_API_KEY=
|
||||
|
||||
# Defaults
|
||||
#
|
||||
|
|
|
|||
123
CHANGELOG.md
123
CHANGELOG.md
|
|
@ -11,20 +11,40 @@ way to update this template, but currently, we follow a pattern:
|
|||
* Patch (v0.0.**X**): Bug fixes and small changes to components.
|
||||
|
||||
---
|
||||
|
||||
## v2.0.0 2018-09-19
|
||||
|
||||
* [add] New default map provider (Mapbox) and complete refactoring to all map and geocoding
|
||||
components. [#888](https://github.com/sharetribe/flex-template-web/pull/888)
|
||||
|
||||
**Note:** Before updating to version 2.0.0, you should very carefully track customizations that
|
||||
you have made to following components:
|
||||
|
||||
* **LocationAutocompleteInput**
|
||||
* **Map**
|
||||
* **SearchPage** (especially previous `onIdle` function)
|
||||
* **SearchMap**
|
||||
* **SearchMapPriceLabel**
|
||||
* **SearchMapGroupLabel**
|
||||
* **SearchMapInfoCard**
|
||||
|
||||
To get a better understanding of what has changed, you should read documents about how to
|
||||
[integrate to map providers](./docs/map-providers.md) and especially
|
||||
[changing map provider to Google Maps](./docs/google-maps.md)
|
||||
|
||||
## v1.4.3 2018-09-15
|
||||
|
||||
* [fix] fuzzy location didn't change when listing location changed.
|
||||
[#931](https://github.com/sharetribe/flex-template-web/pull/931)
|
||||
* [fix] obfuscatedCoordinatesImpl didn't always return coordinates within given
|
||||
offset radius.
|
||||
* [fix] obfuscatedCoordinatesImpl didn't always return coordinates within given offset radius.
|
||||
[#930](https://github.com/sharetribe/flex-template-web/pull/930)
|
||||
* [fix] LocationAutocompleteInput: blur input when selecting by enter
|
||||
to prevent flash of default predictions.
|
||||
[#928](https://github.com/sharetribe/flex-template-web/pull/928)
|
||||
* [fix] LocationAutocompleteInput: selecting with enter key prevented
|
||||
while fetching predictions.
|
||||
* [fix] LocationAutocompleteInput: blur input when selecting by enter to prevent flash of default
|
||||
predictions. [#928](https://github.com/sharetribe/flex-template-web/pull/928)
|
||||
* [fix] LocationAutocompleteInput: selecting with enter key prevented while fetching predictions.
|
||||
[#923](https://github.com/sharetribe/flex-template-web/pull/923)
|
||||
|
||||
## v1.4.2 2018-09-06
|
||||
|
||||
* [add] Reduce character queries on LocationAutocompleteInput to reduce geocoding costs.
|
||||
[#883](https://github.com/sharetribe/flex-template-web/pull/883)
|
||||
* [change] Update git links and improve documentation
|
||||
|
|
@ -37,26 +57,24 @@ way to update this template, but currently, we follow a pattern:
|
|||
[#906](https://github.com/sharetribe/flex-template-web/pull/906)
|
||||
|
||||
## v1.4.1 2018-08-21
|
||||
|
||||
* [fix] Fix window resize redirecting to search page with reusable map component
|
||||
[#905](https://github.com/sharetribe/flex-template-web/pull/905)
|
||||
|
||||
* [change] Maps configuration has been restructured. The new
|
||||
configuration is agnostic of the maps provider in use and works with
|
||||
both Google Maps as well as Mapbox.
|
||||
* [change] Maps configuration has been restructured. The new configuration is agnostic of the maps
|
||||
provider in use and works with both Google Maps as well as Mapbox.
|
||||
|
||||
The fuzzy location circle has less configuration, but otherwise all
|
||||
the previous settings can be set also in the new configuration. See
|
||||
`config.js` for details.
|
||||
The fuzzy location circle has less configuration, but otherwise all the previous settings can be
|
||||
set also in the new configuration. See `config.js` for details.
|
||||
|
||||
The default location searches are now enabled in the
|
||||
`.env-template`. For old installations, the
|
||||
`REACT_APP_DEFAULT_SEARCHES_ENABLED` env var should be set to
|
||||
`true`. The default searches can then be configured in
|
||||
`src/default-location-searches.js`.
|
||||
The default location searches are now enabled in the `.env-template`. For old installations, the
|
||||
`REACT_APP_DEFAULT_SEARCHES_ENABLED` env var should be set to `true`. The default searches can
|
||||
then be configured in `src/default-location-searches.js`.
|
||||
|
||||
[#900](https://github.com/sharetribe/flex-template-web/pull/900)
|
||||
|
||||
## v1.4.0 2018-08-17
|
||||
|
||||
* [change] Put availability calendar behind a feature flag
|
||||
[#902](https://github.com/sharetribe/flex-template-web/pull/902)
|
||||
* [fix] Drop date time from time slots request query params
|
||||
|
|
@ -65,37 +83,36 @@ way to update this template, but currently, we follow a pattern:
|
|||
[#901](https://github.com/sharetribe/flex-template-web/pull/901)
|
||||
* [add] Map component (used in ListingPage) using Mapbox instead of Google Maps
|
||||
[#896](https://github.com/sharetribe/flex-template-web/pull/896)
|
||||
* [add] Listing availability
|
||||
[#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)
|
||||
* [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
|
||||
in `src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js`.
|
||||
* [add] Listing availability [#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)
|
||||
* [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 in
|
||||
`src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js`.
|
||||
[#894](https://github.com/sharetribe/flex-template-web/pull/894)
|
||||
* [change] Removed the `country` parameter from the search page as it
|
||||
was not used anywhere.
|
||||
* [change] Removed the `country` parameter from the search page as it was not used anywhere.
|
||||
[#893](https://github.com/sharetribe/flex-template-web/pull/893)
|
||||
|
||||
## v1.3.2 2018-08-07
|
||||
* [change] Update the Sharetribe Flex SDK to the 1.0.0 version in NPM.
|
||||
All the `sharetribe-sdk` imports are now using the new package name
|
||||
`sharetribe-flex-sdk`.
|
||||
|
||||
* [change] Update the Sharetribe Flex SDK to the 1.0.0 version in NPM. All the `sharetribe-sdk`
|
||||
imports are now using the new package name `sharetribe-flex-sdk`.
|
||||
[#884](https://github.com/sharetribe/flex-template-web/pull/884)
|
||||
* [change] Reusable SearchMap. Fixed the original reverted version. (Includes audit exception 678)
|
||||
[#882](https://github.com/sharetribe/flex-template-web/pull/882)
|
||||
|
||||
## v1.3.1
|
||||
* [fix] Hotfix: reverting the usage of ReusableMapContainer due to
|
||||
production build error.
|
||||
|
||||
* [fix] Hotfix: reverting the usage of ReusableMapContainer due to production build error.
|
||||
[#881](https://github.com/sharetribe/flex-template-web/pull/881)
|
||||
|
||||
## v1.3.0
|
||||
* [change] Reusable SearchMap.
|
||||
[#877](https://github.com/sharetribe/flex-template-web/pull/877)
|
||||
|
||||
* [change] Reusable SearchMap. [#877](https://github.com/sharetribe/flex-template-web/pull/877)
|
||||
* [fix] Fix a search filters panel bug where selecting an option in a multi select filter ends up
|
||||
invoking a mobile filter callback function.
|
||||
[#876](https://github.com/sharetribe/flex-template-web/pull/876)
|
||||
|
|
@ -103,22 +120,26 @@ way to update this template, but currently, we follow a pattern:
|
|||
[#874](https://github.com/sharetribe/flex-template-web/pull/874)
|
||||
|
||||
## v1.2.2
|
||||
|
||||
* [change] Change static map to dynamic map when clicked.
|
||||
[#871](https://github.com/sharetribe/flex-template-web/pull/871)
|
||||
|
||||
## v1.2.1
|
||||
|
||||
* [fix] Lazy load map only if the map is near current viewport.
|
||||
[#871](https://github.com/sharetribe/flex-template-web/pull/871)
|
||||
|
||||
## v1.2.0
|
||||
* [change] Use Google's static map on ListingPage.
|
||||
This is a reaction to pricing change of Google Maps APIs.
|
||||
[#869](https://github.com/sharetribe/flex-template-web/pull/869)
|
||||
* [change] Use sessionTokens and fields for Autocomplete calls to Google Maps.
|
||||
This is a reaction to pricing change of Google Maps APIs.
|
||||
|
||||
* [change] Use Google's static map on ListingPage. This is a reaction to pricing change of Google
|
||||
Maps APIs. [#869](https://github.com/sharetribe/flex-template-web/pull/869)
|
||||
* [change] Use sessionTokens and fields for Autocomplete calls to Google Maps. This is a reaction to
|
||||
pricing change of Google Maps APIs.
|
||||
[#867](https://github.com/sharetribe/flex-template-web/pull/867)
|
||||
* [change] Change TransactionPage state management in loadData.
|
||||
[#863](https://github.com/sharetribe/flex-template-web/pull/863), [#865](https://github.com/sharetribe/flex-template-web/pull/865) & [#866](https://github.com/sharetribe/flex-template-web/pull/866)
|
||||
[#863](https://github.com/sharetribe/flex-template-web/pull/863),
|
||||
[#865](https://github.com/sharetribe/flex-template-web/pull/865) &
|
||||
[#866](https://github.com/sharetribe/flex-template-web/pull/866)
|
||||
* [fix] Fix submit button state on contact details page.
|
||||
[#864](https://github.com/sharetribe/flex-template-web/pull/864)
|
||||
* [fix] Fix listing page host section layout bug.
|
||||
|
|
@ -126,24 +147,23 @@ way to update this template, but currently, we follow a pattern:
|
|||
* [fix] Fix initial message input clearing too early in checkout page.
|
||||
[#861](https://github.com/sharetribe/flex-template-web/pull/861)
|
||||
* [fix] Fix setting Topbar search input initial value.
|
||||
* [change] Update Redux to v4
|
||||
[#859](https://github.com/sharetribe/flex-template-web/pull/859)
|
||||
* [change] Update Redux to v4 [#859](https://github.com/sharetribe/flex-template-web/pull/859)
|
||||
* [fix] Fix setting Topbar search input initial value
|
||||
[#857](https://github.com/sharetribe/flex-template-web/pull/857)
|
||||
|
||||
## v1.1.0
|
||||
|
||||
* [fix] Improve slug creation (slashes were breaking rendering in some environments)
|
||||
[#850](https://github.com/sharetribe/flex-template-web/pull/850)
|
||||
* [fix] Anonymous user should see contact link on UserCard
|
||||
[#851](https://github.com/sharetribe/flex-template-web/pull/851)
|
||||
* [fix] Persisting booking request details across authentication
|
||||
[#852](https://github.com/sharetribe/flex-template-web/pull/852)
|
||||
* [change] Footer styles changed to more generic (no disappearing columns etc.)
|
||||
If you have made changes to Footer, consider extracting it to different component before update.
|
||||
* [change] Footer styles changed to more generic (no disappearing columns etc.) If you have made
|
||||
changes to Footer, consider extracting it to different component before update.
|
||||
[#853](https://github.com/sharetribe/flex-template-web/pull/853)
|
||||
* [change] Logo customization refactored to be easier. Check CheckoutPage, TopbarDesktop and Footer
|
||||
after update.
|
||||
[#854](https://github.com/sharetribe/flex-template-web/pull/854)
|
||||
after update. [#854](https://github.com/sharetribe/flex-template-web/pull/854)
|
||||
* [fix] Fix showing reviews from banned users.
|
||||
[#855](https://github.com/sharetribe/flex-template-web/pull/855)
|
||||
|
||||
|
|
@ -156,9 +176,8 @@ way to update this template, but currently, we follow a pattern:
|
|||
[#846](https://github.com/sharetribe/flex-template-web/pull/846)
|
||||
* [fix] Add missing styles for ModalMissingInformation from Topbar
|
||||
[#847](https://github.com/sharetribe/flex-template-web/pull/847)
|
||||
* [fix] API does not return all image variants anymore, this adds correct variants to update
|
||||
contact details call.
|
||||
[#848](https://github.com/sharetribe/flex-template-web/pull/848)
|
||||
* [fix] API does not return all image variants anymore, this adds correct variants to update contact
|
||||
details call. [#848](https://github.com/sharetribe/flex-template-web/pull/848)
|
||||
|
||||
## v0.3.1
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ Documentation for specific topics can be found in the following files:
|
|||
|
||||
* [Customization guide](customization-guide.md)
|
||||
* [Folder structure](folder-structure.md)
|
||||
* [Integration to map providers](map-providers.md)
|
||||
* [Translations](translations.md)
|
||||
* [Styling a marketplace](styling.md)
|
||||
* [Static pages](static-pages.md)
|
||||
|
|
@ -49,7 +50,6 @@ Documentation for specific topics can be found in the following files:
|
|||
* [Content Security Policy (CSP)](content-security-policy.md)
|
||||
* [Original create-react-app documentation](https://github.com/sharetribe/create-react-app/blob/master/packages/react-scripts/template/README.md)
|
||||
* [Customization checklist](customization-checklist.md)
|
||||
* [Google Maps](google-maps.md)
|
||||
* [Icons](icons.md)
|
||||
|
||||
The application was bootstrapped with a forked version of
|
||||
|
|
|
|||
|
|
@ -11,7 +11,8 @@ Some generic things to update and check when starting to customize the template.
|
|||
* [Terms of Service](terms-of-service-and-privacy-policy.md#terms-of-service)
|
||||
* [Privacy Policy](terms-of-service-and-privacy-policy.md#privacy-policy)
|
||||
* [Social media sharing graphics](../src/components/Page/Page.js);
|
||||
* [Logo](../src/components/Logo/Logo.js) Change and check that it works on Topbar, Footer, and CheckoutPage
|
||||
* [Logo](../src/components/Logo/Logo.js) Change and check that it works on Topbar, Footer, and
|
||||
CheckoutPage
|
||||
* [Default background image](../src/assets/background-1440.jpg)
|
||||
* [Maps Marker icon](../src/components/Map/images/marker-32x32.png)
|
||||
* [Config: update environment variables](../src/config.js)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ them have defaults that work for development environment. For production deploys
|
|||
|
||||
| Variable | Description |
|
||||
| ----------------------------------------- | ----------------------------------------------------------------------------------------------------------------- |
|
||||
| REACT_APP_GOOGLE_MAPS_API_KEY | See: [Google Maps API key](./google-maps.md) |
|
||||
| REACT_APP_MAPBOX_ACCESS_TOKEN | See: [Integrating to map providers](./map-providers.md) |
|
||||
| REACT_APP_GOOGLE_MAPS_API_KEY | See: [Google Maps API key](./google-maps.md) (Alternative map provider) |
|
||||
| REACT_APP_SHARETRIBE_SDK_CLIENT_ID | Client ID (API key). You will get this from the Sharetribe team. |
|
||||
| REACT_APP_STRIPE_PUBLISHABLE_KEY | Stripe publishable API key for generating tokens with Stripe API. Use test key (prefix pk*test*) for development. |
|
||||
| REACT_APP_SHARETRIBE_SDK_BASE_URL | The base url to access the Sharetribe Flex Marketplace API. |
|
||||
|
|
|
|||
|
|
@ -42,8 +42,8 @@ After cloning the repo, your project should look like this:
|
|||
|
||||
## public/index.html
|
||||
|
||||
This is the page template. It includes fonts, Stripe SDK, Google Maps JavaScript API, and app icons.
|
||||
It also specifies placeholders for tags generated by
|
||||
This is the page template. It includes fonts, Stripe SDK, Mapbox API (and Google Maps JavaScript
|
||||
API), and app icons. It also specifies placeholders for tags generated by
|
||||
[React Helmet](https://github.com/nfl/react-helmet)
|
||||
|
||||
## src/
|
||||
|
|
|
|||
|
|
@ -1,8 +1,14 @@
|
|||
# Google Maps
|
||||
|
||||
The Flex template for web uses the Google Maps API for showing a map and in searching locations in
|
||||
the search autocompletion. This document describes how to setup the API key for the API requests to
|
||||
work properly.
|
||||
Flex Template for Web (FTW) offers out of the box support for Google Maps API for showing a map and
|
||||
searching locations with search autocompletion. This document describes how to set up the API key
|
||||
for the API requests to work properly.
|
||||
|
||||
> Note: before making the change to Google Maps, you should consider if you are OK with their
|
||||
> current pricing. There's a pricing calculator available in their
|
||||
> [pricing page](https://cloud.google.com/maps-platform/pricing/). FTW's default map provider is
|
||||
> Mapbox, which is often cheaper.
|
||||
> [Learn more about how to use Mapbox or some other map provider instead of Google Maps](https://github.com/sharetribe/flex-template-web/blob/e6034c7690c095553f44092b032689cd6b6f7546/docs/map-providers.md).
|
||||
|
||||
## Generate a Google Maps API key
|
||||
|
||||
|
|
@ -28,15 +34,147 @@ REACT_APP_GOOGLE_MAPS_API_KEY=my-key-here
|
|||
|
||||
## Setup common locations to reduce typing
|
||||
|
||||
The location autocomplete input in the landing page and the topbar can
|
||||
be configured to have specific locations shown by default when the
|
||||
user focuses on the input and hasn't yet typed in any searches. This
|
||||
reduces the typing required for common searches, and also reduces the
|
||||
need to use the Google Places geolocation API that much.
|
||||
The location autocomplete-input in the landing page and the topbar can be configured to have
|
||||
specific locations shown by default when the user focuses on the input and hasn't yet typed in any
|
||||
searches. This reduces the typing required for common searches and also reduces the need to use
|
||||
Google Map Places API that much.
|
||||
|
||||
The default locations can be configured in
|
||||
[src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js](../src/components/LocationAutocompleteInput/GeocoderGoogleMaps.js).
|
||||
To use default searches, another environment variable needs to be set:
|
||||
|
||||
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.
|
||||
```
|
||||
REACT_APP_DEFAULT_SEARCHES_ENABLED=true
|
||||
```
|
||||
|
||||
The default locations have been described in file:
|
||||
[src/default-location-searches.js](../src/default-location-searches.js).
|
||||
|
||||
The same environment variable also shows "current location" suggestion, which will make the browser
|
||||
to ask user's current location. This is a fast way to search listings nearby. You can specify
|
||||
whether to use the current location from [config.js](../src/config.js). Search for variables:
|
||||
`suggestCurrentLocation` and `currentLocationBoundsDistance`.
|
||||
|
||||
## Change components: use Google Map versions instead of Mapbox
|
||||
|
||||
If you wish to use Google Maps instead of Mapbox, you need to make some changes to FTW default
|
||||
setup.
|
||||
|
||||
### 1. Include Google Map script instead of Mapbox scripts
|
||||
|
||||
Mapbox related scripts can be removed from index.html and instead use Google Map script described in
|
||||
comments.
|
||||
|
||||
_public/index.html:_
|
||||
|
||||
```html
|
||||
<script src="%PUBLIC_URL%/static/scripts/mapbox/mapbox-sdk.min.js"></script>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.js"></script>
|
||||
<script>
|
||||
window.mapboxgl.accessToken = '%REACT_APP_MAPBOX_ACCESS_TOKEN%';
|
||||
</script>
|
||||
<!--
|
||||
If Google Maps is used instead of Mapbox, you need to include Google's script instead:
|
||||
<script src="https://maps.googleapis.com/maps/api/js?key=%REACT_APP_GOOGLE_MAPS_API_KEY%&libraries=places"></script>
|
||||
-->
|
||||
```
|
||||
|
||||
### 2. Searching with Google's geocoding API
|
||||
|
||||
Location search aka LocationAutocompleteInput should use Google Map specific geocoder. The correct
|
||||
import is written to the comments of LocationAutocompleteInput component.
|
||||
|
||||
_src/components/LocationAutocompleteInput/LocationAutocompleteInputImpl.js:_
|
||||
|
||||
```js
|
||||
import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderMapbox';
|
||||
// import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderGoogleMaps';
|
||||
```
|
||||
|
||||
Furthermore, Google Map states in their terms of service that Google logo needs to be visible when
|
||||
using their geocoding service. It is available as a background image below the autocomplete
|
||||
predictions. However, there needs to be enough padding for that logo. You can change the padding
|
||||
through `marketplace.css`.
|
||||
|
||||
_src/marketplace.css:_
|
||||
|
||||
```js
|
||||
/* Google Maps needs 72px bottom padding to accommodate logo, Mapbox doesn't have one */
|
||||
--locationAutocompleteBottomPadding: 8px;
|
||||
```
|
||||
|
||||
### 3. Show correct map on ListingPage (Map component)
|
||||
|
||||
Google Map version (containing both static and dynamic maps) can be taken into use by importing
|
||||
correct map subcomponent.
|
||||
|
||||
_src/components/Map/Map.js:_
|
||||
|
||||
```js
|
||||
import { StaticMap, DynamicMap, isMapsLibLoaded } from './MapboxMap';
|
||||
// import { StaticMap, DynamicMap, isMapsLibLoaded } from './GoogleMap';
|
||||
```
|
||||
|
||||
### 4. SearchMap.js
|
||||
|
||||
The most complex change is happening in SearchPage. First, you need to import `SearchMapWithMapbox`
|
||||
instead of `SearchMapWithGoogleMap`.
|
||||
|
||||
_src/components/SearchMap/SearchMap.js:_
|
||||
|
||||
Remove this:
|
||||
|
||||
```js
|
||||
import SearchMapWithMapbox, {
|
||||
LABEL_HANDLE,
|
||||
INFO_CARD_HANDLE,
|
||||
getMapBounds,
|
||||
getMapCenter,
|
||||
fitMapToBounds,
|
||||
isMapsLibLoaded,
|
||||
} from './SearchMapWithMapbox';
|
||||
```
|
||||
|
||||
And add this instead:
|
||||
|
||||
```js
|
||||
import SearchMapWithGoogleMap, {
|
||||
LABEL_HANDLE,
|
||||
INFO_CARD_HANDLE,
|
||||
getMapBounds,
|
||||
getMapCenter,
|
||||
fitMapToBounds,
|
||||
isMapsLibLoaded,
|
||||
} from './SearchMapWithGoogleMap';
|
||||
```
|
||||
|
||||
Then, in `render` method, you need to put `SearchMapWithGoogleMap` component into use by replacing
|
||||
`SearchMapWithMapbox` which is defined inside `ReusableMapContainer`. The component with correct
|
||||
props is already there in the comments:
|
||||
|
||||
```js
|
||||
// When changing from default map provider to Google Maps, you should use the following
|
||||
// component instead of SearchMapWithMapbox:
|
||||
//
|
||||
// <SearchMapWithGoogleMap
|
||||
// containerElement={
|
||||
// <div id="search-map-container" className={classes} onClick={this.onMapClicked} />
|
||||
// }
|
||||
// mapElement={<div className={mapRootClassName || css.mapRoot} />}
|
||||
// bounds={bounds}
|
||||
// center={center}
|
||||
// location={location}
|
||||
// infoCardOpen={infoCardOpen}
|
||||
// listings={listings}
|
||||
// activeListingId={activeListingId}
|
||||
// mapComponentRefreshToken={this.state.mapReattachmentCount}
|
||||
// createURLToListing={this.createURLToListing}
|
||||
// onListingClicked={this.onListingClicked}
|
||||
// onListingInfoCardClicked={this.onListingInfoCardClicked}
|
||||
// onMapLoad={this.onMapLoadHandler}
|
||||
// onMapMoveEnd={onMapMoveEnd}
|
||||
// zoom={zoom}
|
||||
// />
|
||||
```
|
||||
|
||||
The only extra step is to make `mapRootClassName` property available from `this.props` at the
|
||||
beginning of the `render` method.
|
||||
|
|
|
|||
70
docs/map-providers.md
Normal file
70
docs/map-providers.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# Integrating to map providers
|
||||
|
||||
Choice of map provider can significantly impact your costs. Flex Template for Web (FTW) originally
|
||||
supported only [Google Maps](./google-maps.md) out of the box, but after Google increased the
|
||||
pricing of its APIs a lot, the default provider was changed to Mapbox. The template now supports
|
||||
both Mapbox and Google Maps, and the map library used by Mapbox is used also by several other map
|
||||
providers, so integrating new providers that support this is rather easy (see instructions at the
|
||||
end of this doc).
|
||||
|
||||
## Setting up the Mapbox integration (the default map provider)
|
||||
|
||||
### 1. Generate a Mapbox access key
|
||||
|
||||
Sign up for a Mapbox and go to [account page](https://www.mapbox.com/account/). Then click
|
||||
`Create access token`.
|
||||
|
||||
> Read more about
|
||||
> [access tokens and consider rotating them](https://www.mapbox.com/help/how-access-tokens-work/).
|
||||
|
||||
### 2. Setup the application to use the access key
|
||||
|
||||
The application uses the `REACT_APP_MAPBOX_ACCESS_TOKEN` environment variable for the key value. For
|
||||
local development, you can add the variable in the Gitignored `.env` file in the project root:
|
||||
|
||||
```
|
||||
REACT_APP_MAPBOX_ACCESS_TOKEN=my-access-token-here
|
||||
```
|
||||
|
||||
### 3. Setup common locations to reduce typing
|
||||
|
||||
The location autocomplete input in the landing page and the topbar can be configured to have
|
||||
specific locations shown by default when the user focuses on the input and hasn't yet typed in any
|
||||
searches. This reduces the typing required for common searches and also reduces the need to use
|
||||
Mapbox geolocation API that much.
|
||||
|
||||
To use default searches, another environment variable needs to be set:
|
||||
|
||||
```
|
||||
REACT_APP_DEFAULT_SEARCHES_ENABLED=true
|
||||
```
|
||||
|
||||
The default locations are described in
|
||||
[src/default-location-searches.js](../src/default-location-searches.js).
|
||||
|
||||
The same environment variable also shows "current location" suggestion, which will make the browser
|
||||
to ask user's current location. This is a fast way to search listings nearby. You can specify
|
||||
whether to use the current location from [config.js](../src/config.js). Search for variables:
|
||||
`suggestCurrentLocation` and `currentLocationBoundsDistance`.
|
||||
|
||||
### 4. Check rare default configurations
|
||||
|
||||
Mapbox geocoding API doesn't always return bounding boxes for locations. Without bounding box
|
||||
SearchMap component can't adjust zoom level right for that particular place. Therefore there are
|
||||
default bounding boxes defined to different place types in
|
||||
[Mapbox specific geocoder](../src/components/LocationAutocompleteInput/GeocoderMapbox.js).
|
||||
|
||||
## Changing the map providers
|
||||
|
||||
### How to change from Mapbox to Google Maps
|
||||
|
||||
It is possible to use Google Map instead of the default map provider. Read more from
|
||||
[Google Map setup guide](./google-maps.md)
|
||||
|
||||
### How to use other map providers
|
||||
|
||||
The default map setup of FTW uses library called `mapbox-gl-js`. It is supported by quite many other
|
||||
map providers too. Thus, if you wish to use a map provider other than Google Maps or Mapbox, first
|
||||
check if the map provider you are considering is supporting this library. If they are, the change
|
||||
might be quite easy. Note: if you change the map tile provider you should also change geocoding API
|
||||
too (i.e. the API endpoint for `LocationAutocompleteInput` component).
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "app",
|
||||
"version": "1.4.3",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
"final-form-arrays": "^1.0.4",
|
||||
"helmet": "^3.12.0",
|
||||
"lodash": "^4.17.5",
|
||||
"mapbox-gl-multitouch": "^1.0.3",
|
||||
"moment": "^2.20.1",
|
||||
"object.entries": "^1.0.4",
|
||||
"object.values": "^1.0.4",
|
||||
|
|
|
|||
|
|
@ -143,7 +143,22 @@
|
|||
})(document);
|
||||
</script>
|
||||
|
||||
<script src="https://maps.googleapis.com/maps/api/js?key=%REACT_APP_GOOGLE_MAPS_API_KEY%&libraries=places"></script>
|
||||
<!--
|
||||
NOTE: remember to update mapbox-sdk.min.js to a new version regularly.
|
||||
mapbox-sdk.min.js is included from static folder for CSP purposes.
|
||||
We didn't bundle it to the main app since it would unnecessarily slow down initial rendering.
|
||||
-->
|
||||
<script src="%PUBLIC_URL%/static/scripts/mapbox/mapbox-sdk.min.js"></script>
|
||||
<link href="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.css" rel="stylesheet" />
|
||||
<script src="https://api.mapbox.com/mapbox-gl-js/v0.47.0/mapbox-gl.js"></script>
|
||||
<script>
|
||||
window.mapboxgl.accessToken = '%REACT_APP_MAPBOX_ACCESS_TOKEN%';
|
||||
</script>
|
||||
<!--
|
||||
If Google Maps is used instead of Mapbox, you need to include Google's script instead:
|
||||
<script src="https://maps.googleapis.com/maps/api/js?key=%REACT_APP_GOOGLE_MAPS_API_KEY%&libraries=places"></script>
|
||||
-->
|
||||
|
||||
<!--
|
||||
Stripe script should be on every page, not just the pages that
|
||||
use the API:
|
||||
|
|
|
|||
26
public/static/scripts/mapbox/LICENCE
Normal file
26
public/static/scripts/mapbox/LICENCE
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
Copyright (c) 2018, Mapbox
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
||||
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The views and conclusions contained in the software and documentation are those
|
||||
of the authors and should not be interpreted as representing official policies,
|
||||
either expressed or implied, of the FreeBSD Project.
|
||||
3900
public/static/scripts/mapbox/mapbox-sdk.js
Normal file
3900
public/static/scripts/mapbox/mapbox-sdk.js
Normal file
File diff suppressed because it is too large
Load diff
1
public/static/scripts/mapbox/mapbox-sdk.min.js
vendored
Normal file
1
public/static/scripts/mapbox/mapbox-sdk.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -5,6 +5,7 @@ const self = "'self'";
|
|||
const unsafeInline = "'unsafe-inline'";
|
||||
const unsafeEval = "'unsafe-eval'";
|
||||
const data = 'data:';
|
||||
const blob = 'blob:';
|
||||
const devImagesMaybe = dev ? ['*.localhost:8000'] : [];
|
||||
|
||||
// Default CSP whitelist.
|
||||
|
|
@ -14,10 +15,13 @@ const devImagesMaybe = dev ? ['*.localhost:8000'] : [];
|
|||
const defaultDirectives = {
|
||||
baseUri: [self],
|
||||
defaultSrc: [self],
|
||||
childSrc: [blob],
|
||||
connectSrc: [
|
||||
self,
|
||||
process.env.REACT_APP_SHARETRIBE_SDK_BASE_URL,
|
||||
'maps.googleapis.com',
|
||||
'*.tiles.mapbox.com',
|
||||
'api.mapbox.com',
|
||||
|
||||
// Google Analytics
|
||||
'www.google-analytics.com',
|
||||
|
|
@ -31,6 +35,7 @@ const defaultDirectives = {
|
|||
imgSrc: [
|
||||
self,
|
||||
data,
|
||||
blob,
|
||||
...devImagesMaybe,
|
||||
'*.imgix.net',
|
||||
'sharetribe.imgix.net', // Safari 9.1 didn't recognize asterisk rule.
|
||||
|
|
@ -39,6 +44,7 @@ const defaultDirectives = {
|
|||
'lorempixel.com',
|
||||
'via.placeholder.com',
|
||||
|
||||
'api.mapbox.com',
|
||||
'maps.googleapis.com',
|
||||
'*.gstatic.com',
|
||||
'*.googleapis.com',
|
||||
|
|
@ -57,10 +63,11 @@ const defaultDirectives = {
|
|||
unsafeEval,
|
||||
data,
|
||||
'maps.googleapis.com',
|
||||
'api.mapbox.com',
|
||||
'*.google-analytics.com',
|
||||
'js.stripe.com',
|
||||
],
|
||||
styleSrc: [self, unsafeInline, 'fonts.googleapis.com'],
|
||||
styleSrc: [self, unsafeInline, 'fonts.googleapis.com', 'api.mapbox.com'],
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -6,7 +6,26 @@ const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes;
|
|||
|
||||
export const CURRENT_LOCATION_ID = 'current-location';
|
||||
|
||||
const GENERATED_BOUNDS_DEFAULT_DISTANCE = 500; // meters
|
||||
// Distances for generated bounding boxes for different Mapbox place types
|
||||
const PLACE_TYPE_BOUNDS_DISTANCES = {
|
||||
address: 500,
|
||||
country: 2000,
|
||||
region: 2000,
|
||||
postcode: 2000,
|
||||
district: 2000,
|
||||
place: 2000,
|
||||
locality: 2000,
|
||||
neighborhood: 2000,
|
||||
poi: 2000,
|
||||
'poi.landmark': 2000,
|
||||
};
|
||||
|
||||
const locationBounds = (latlng, distance) => {
|
||||
if (!latlng) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bounds = new window.mapboxgl.LngLat(latlng.lng, latlng.lat).toBounds(distance);
|
||||
return new SDKLatLngBounds(
|
||||
new SDKLatLng(bounds.getNorth(), bounds.getEast()),
|
||||
|
|
@ -23,12 +42,24 @@ const placeOrigin = prediction => {
|
|||
};
|
||||
|
||||
const placeBounds = prediction => {
|
||||
if (prediction && Array.isArray(prediction.bbox) && prediction.bbox.length === 4) {
|
||||
// Bounds in Mapbox features are represented as [minX, minY, maxX, maxY]
|
||||
return new SDKLatLngBounds(
|
||||
new SDKLatLng(prediction.bbox[3], prediction.bbox[2]),
|
||||
new SDKLatLng(prediction.bbox[1], prediction.bbox[0])
|
||||
);
|
||||
if (prediction) {
|
||||
if (Array.isArray(prediction.bbox) && prediction.bbox.length === 4) {
|
||||
// Bounds in Mapbox features are represented as [minX, minY, maxX, maxY]
|
||||
return new SDKLatLngBounds(
|
||||
new SDKLatLng(prediction.bbox[3], prediction.bbox[2]),
|
||||
new SDKLatLng(prediction.bbox[1], prediction.bbox[0])
|
||||
);
|
||||
} else {
|
||||
// If bounds are not available, generate them around the origin
|
||||
|
||||
// Resolve bounds distance based on place type
|
||||
const placeType = Array.isArray(prediction.place_type) && prediction.place_type[0];
|
||||
|
||||
const distance =
|
||||
(placeType && PLACE_TYPE_BOUNDS_DISTANCES[placeType]) || GENERATED_BOUNDS_DEFAULT_DISTANCE;
|
||||
|
||||
return locationBounds(placeOrigin(prediction), distance);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import classNames from 'classnames';
|
|||
import debounce from 'lodash/debounce';
|
||||
import { IconSpinner } from '../../components';
|
||||
import { propTypes } from '../../util/types';
|
||||
import config from '../../config';
|
||||
|
||||
import IconHourGlass from './IconHourGlass';
|
||||
import IconCurrentLocation from './IconCurrentLocation';
|
||||
import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderGoogleMaps';
|
||||
// import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderMapbox';
|
||||
import config from '../../config';
|
||||
import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderMapbox';
|
||||
// import Geocoder, { GeocoderAttribution, CURRENT_LOCATION_ID } from './GeocoderGoogleMaps';
|
||||
|
||||
import css from './LocationAutocompleteInput.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
import React, { Component } from 'react';
|
||||
import { string, shape, number, object } from 'prop-types';
|
||||
// This MultiTouch lib is used for 2-finger panning.
|
||||
// which prevents user to experience map-scroll trap, while scrolling the page.
|
||||
// https://github.com/mapbox/mapbox-gl-js/issues/2618
|
||||
// TODO: we should add an overlay with text "use two fingers to pan".
|
||||
import MultiTouch from 'mapbox-gl-multitouch';
|
||||
import uniqueId from 'lodash/uniqueId';
|
||||
import { circlePolyline } from '../../util/maps';
|
||||
import config from '../../config';
|
||||
|
|
@ -65,7 +70,8 @@ class DynamicMapboxMap extends Component {
|
|||
zoom,
|
||||
scrollZoom: false,
|
||||
});
|
||||
this.map.addControl(new window.mapboxgl.NavigationControl(), 'top-left');
|
||||
this.map.addControl(new window.mapboxgl.NavigationControl({ showCompass: false }), 'top-left');
|
||||
this.map.addControl(new MultiTouch());
|
||||
|
||||
if (mapsConfig.fuzzy.enabled) {
|
||||
this.map.on('load', () => {
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@ import { bool, number, object, string } from 'prop-types';
|
|||
import classNames from 'classnames';
|
||||
import { propTypes } from '../../util/types';
|
||||
import config from '../../config';
|
||||
import { StaticMap, DynamicMap, isMapsLibLoaded } from './GoogleMap';
|
||||
// import { StaticMap, DynamicMap, isMapsLibLoaded } from './MapboxMap';
|
||||
import { StaticMap, DynamicMap, isMapsLibLoaded } from './MapboxMap';
|
||||
// import { StaticMap, DynamicMap, isMapsLibLoaded } from './GoogleMap';
|
||||
|
||||
import css from './Map.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ class ReusableMapContainer extends React.Component {
|
|||
|
||||
componentWillUnmount() {
|
||||
this.el.classList.add(css.reusableMapHidden);
|
||||
this.el.classList.add(this.props.reusableMapHiddenHandle);
|
||||
this.mountNode.removeChild(this.el);
|
||||
document.body.appendChild(this.el);
|
||||
}
|
||||
|
|
@ -90,6 +91,7 @@ class ReusableMapContainer extends React.Component {
|
|||
renderChildren();
|
||||
} else {
|
||||
this.el.classList.remove(css.reusableMapHidden);
|
||||
this.el.classList.remove(this.props.reusableMapHiddenHandle);
|
||||
|
||||
if (this.mountNode && !this.mountNode.firstChild) {
|
||||
// Move the map to correct location if we have rendered the map before
|
||||
|
|
@ -125,6 +127,7 @@ ReusableMapContainer.defaultProps = {
|
|||
ReusableMapContainer.propTypes = {
|
||||
children: node.isRequired,
|
||||
className: string,
|
||||
reusableMapHiddenHandle: string.isRequired,
|
||||
};
|
||||
|
||||
export default ReusableMapContainer;
|
||||
|
|
|
|||
35
src/components/SearchMap/SearchMap.helpers.js
Normal file
35
src/components/SearchMap/SearchMap.helpers.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import groupBy from 'lodash/groupBy';
|
||||
import reduce from 'lodash/reduce';
|
||||
|
||||
/**
|
||||
* hasParentWithClassName searches class name from parent elements of given target
|
||||
* @param {Node} target - element whose parent might contain given class.
|
||||
* @param {String} className - class name string to be found
|
||||
*/
|
||||
export const hasParentWithClassName = (target, className) => {
|
||||
return [...document.querySelectorAll(`.${className}`)].some(
|
||||
el => el !== target && el.contains(target)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Listings array grouped by geolocation
|
||||
* @param {Array} mapListings - listings to be grouped on map
|
||||
* @return {Object} - Object where coordinate pair is the key to different listings
|
||||
*/
|
||||
export const groupedByCoordinates = mapListings => {
|
||||
return groupBy(mapListings, l => {
|
||||
const g = l.attributes.geolocation;
|
||||
return `${g.lat}-${g.lng}`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Listings (in location based object literal) is mapped to array
|
||||
* @param {Object} mapListings - listings to be grouped on map
|
||||
* @return {Array} - An array where items are arrays of listings
|
||||
* (They are arrays containing all the listings in that location)
|
||||
*/
|
||||
export const reducedToArray = mapListings => {
|
||||
return reduce(mapListings, (acc, listing) => acc.concat([listing]), []);
|
||||
};
|
||||
|
|
@ -1,80 +1,27 @@
|
|||
import React, { Component } from 'react';
|
||||
import { arrayOf, bool, func, number, string, shape, object } from 'prop-types';
|
||||
import { arrayOf, func, number, string, shape, object } from 'prop-types';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { withGoogleMap, GoogleMap } from 'react-google-maps';
|
||||
import classNames from 'classnames';
|
||||
import groupBy from 'lodash/groupBy';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import reduce from 'lodash/reduce';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
import { createResourceLocatorString } from '../../util/routes';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { obfuscatedCoordinates } from '../../util/maps';
|
||||
import { googleBoundsToSDKBounds } from '../../util/googleMaps';
|
||||
import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components';
|
||||
import config from '../../config';
|
||||
|
||||
import { hasParentWithClassName } from './SearchMap.helpers.js';
|
||||
import SearchMapWithMapbox, {
|
||||
LABEL_HANDLE,
|
||||
INFO_CARD_HANDLE,
|
||||
getMapBounds,
|
||||
getMapCenter,
|
||||
fitMapToBounds,
|
||||
isMapsLibLoaded,
|
||||
} from './SearchMapWithMapbox';
|
||||
import ReusableMapContainer from './ReusableMapContainer';
|
||||
import css from './SearchMap.css';
|
||||
|
||||
const LABEL_HANDLE = 'SearchMapLabel';
|
||||
const INFO_CARD_HANDLE = 'SearchMapInfoCard';
|
||||
|
||||
/**
|
||||
* Fit part of map (descriped with bounds) to visible map-viewport
|
||||
*
|
||||
* @param {Object} map - map that needs to be centered with given bounds
|
||||
* @param {SDK.LatLngBounds} bounds - the area that needs to be visible when map loads.
|
||||
*/
|
||||
const fitMapToBounds = (map, bounds, padding) => {
|
||||
const { ne, sw } = bounds || {};
|
||||
// map bounds as string literal for google.maps
|
||||
const mapBounds = bounds ? { north: ne.lat, east: ne.lng, south: sw.lat, west: sw.lng } : null;
|
||||
|
||||
// If bounds are given, use it (defaults to center & zoom).
|
||||
if (map && mapBounds) {
|
||||
if (padding == null) {
|
||||
map.fitBounds(mapBounds);
|
||||
} else {
|
||||
map.fitBounds(mapBounds, padding);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* hasParentWithClassName searches class name from parent elements of given target
|
||||
* @param {Node} target - element whose parent might contain given class.
|
||||
* @param {String} className - class name string to be found
|
||||
*/
|
||||
const hasParentWithClassName = (target, className) => {
|
||||
return [...document.querySelectorAll(`.${className}`)].some(
|
||||
el => el !== target && el.contains(target)
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Listings array grouped by geolocation
|
||||
* @param {Array} mapListings - listings to be grouped on map
|
||||
* @return {Object} - Object where coordinate pair is the key to different listings
|
||||
*/
|
||||
const groupedByCoordinates = mapListings => {
|
||||
return groupBy(mapListings, l => {
|
||||
const g = l.attributes.geolocation;
|
||||
return `${g.lat}-${g.lng}`;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Listings (in location based object literal) is mapped to array
|
||||
* @param {Object} mapListings - listings to be grouped on map
|
||||
* @return {Array} - An array where items are arrays of listings
|
||||
* (They are arrays containing all the listings in that location)
|
||||
*/
|
||||
const reducedToArray = mapListings => {
|
||||
return reduce(mapListings, (acc, listing) => acc.concat([listing]), []);
|
||||
};
|
||||
const REUSABLE_MAP_HIDDEN_HANDLE = 'reusableMapHidden';
|
||||
|
||||
const withCoordinatesObfuscated = listings => {
|
||||
return listings.map(listing => {
|
||||
|
|
@ -93,116 +40,24 @@ const withCoordinatesObfuscated = listings => {
|
|||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* MapWithGoogleMap uses withGoogleMap HOC.
|
||||
* It handles some of the google map initialization states.
|
||||
*/
|
||||
const MapWithGoogleMap = withGoogleMap(props => {
|
||||
const {
|
||||
activeListingId,
|
||||
center,
|
||||
infoCardOpen,
|
||||
listings,
|
||||
onIdle,
|
||||
onListingClicked,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
onMapLoad,
|
||||
zoom,
|
||||
mapComponentRefreshToken,
|
||||
} = props;
|
||||
|
||||
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
|
||||
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
|
||||
const isActive = activeListingId
|
||||
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
|
||||
: false;
|
||||
|
||||
// If location contains only one listing, print price label
|
||||
if (listingArr.length === 1) {
|
||||
const listing = listingArr[0];
|
||||
const infoCardOpenIds = Array.isArray(infoCardOpen) ? infoCardOpen.map(l => l.id.uuid) : [];
|
||||
|
||||
// if the listing is open, don't print price label
|
||||
if (infoCardOpen != null && infoCardOpenIds.includes(listing.id.uuid)) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<SearchMapPriceLabel
|
||||
isActive={isActive}
|
||||
key={listing.id.uuid}
|
||||
className={LABEL_HANDLE}
|
||||
listing={listing}
|
||||
onListingClicked={onListingClicked}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<SearchMapGroupLabel
|
||||
isActive={isActive}
|
||||
key={listingArr[0].id.uuid}
|
||||
className={LABEL_HANDLE}
|
||||
listings={listingArr}
|
||||
onListingClicked={onListingClicked}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
const listingsArray = Array.isArray(infoCardOpen) ? infoCardOpen : [infoCardOpen];
|
||||
const openedCard = infoCardOpen ? (
|
||||
<SearchMapInfoCard
|
||||
key={listingsArray[0].id.uuid}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
className={INFO_CARD_HANDLE}
|
||||
listings={listingsArray}
|
||||
onListingInfoCardClicked={onListingInfoCardClicked}
|
||||
createURLToListing={createURLToListing}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const controlPosition =
|
||||
typeof window !== 'undefined' && typeof window.google !== 'undefined'
|
||||
? window.google.maps.ControlPosition.LEFT_TOP
|
||||
: 5;
|
||||
|
||||
return (
|
||||
<GoogleMap
|
||||
defaultZoom={zoom}
|
||||
defaultCenter={center}
|
||||
options={{
|
||||
// Disable all controls except zoom
|
||||
mapTypeControl: false,
|
||||
scrollwheel: false,
|
||||
fullscreenControl: false,
|
||||
clickableIcons: false,
|
||||
streetViewControl: false,
|
||||
|
||||
// When infoCard is open, we can't differentiate double click on top of card vs map.
|
||||
disableDoubleClickZoom: !!infoCardOpen,
|
||||
|
||||
zoomControlOptions: {
|
||||
position: controlPosition,
|
||||
},
|
||||
}}
|
||||
ref={onMapLoad}
|
||||
onIdle={onIdle}
|
||||
>
|
||||
{priceLabels}
|
||||
{openedCard}
|
||||
</GoogleMap>
|
||||
);
|
||||
});
|
||||
|
||||
export class SearchMapComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.listings = [];
|
||||
this.googleMap = null;
|
||||
this.mapReattachmentCount = 0;
|
||||
this.state = { infoCardOpen: null };
|
||||
this.mapRef = null;
|
||||
|
||||
let mapReattachmentCount = 0;
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (window.mapReattachmentCount) {
|
||||
mapReattachmentCount = window.mapReattachmentCount;
|
||||
} else {
|
||||
window.mapReattachmentCount = 0;
|
||||
}
|
||||
}
|
||||
|
||||
this.state = { infoCardOpen: null, mapReattachmentCount };
|
||||
|
||||
this.createURLToListing = this.createURLToListing.bind(this);
|
||||
this.onListingInfoCardClicked = this.onListingInfoCardClicked.bind(this);
|
||||
|
|
@ -211,19 +66,6 @@ export class SearchMapComponent extends Component {
|
|||
this.onMapLoadHandler = this.onMapLoadHandler.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (this.googleMap) {
|
||||
const currentBounds = googleBoundsToSDKBounds(this.googleMap.getBounds());
|
||||
|
||||
// Do not call fitMapToBounds if bounds are the same.
|
||||
// Our bounds are viewport bounds, and fitBounds will try to add margins around those bounds
|
||||
// that would result to zoom-loop (bound change -> fitmap -> bounds change -> ...)
|
||||
if (!isEqual(nextProps.bounds, currentBounds) && nextProps.useLocationSearchBounds) {
|
||||
fitMapToBounds(this.googleMap, nextProps.bounds, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.listings = [];
|
||||
}
|
||||
|
|
@ -262,11 +104,11 @@ export class SearchMapComponent extends Component {
|
|||
}
|
||||
|
||||
onMapLoadHandler(map) {
|
||||
this.googleMap = map;
|
||||
this.mapRef = map;
|
||||
|
||||
if (this.googleMap) {
|
||||
if (this.mapRef && this.state.mapReattachmentCount === 0) {
|
||||
// map is ready, let's fit search area's bounds to map's viewport
|
||||
fitMapToBounds(this.googleMap, this.props.bounds, 0);
|
||||
fitMapToBounds(this.mapRef, this.props.bounds, { padding: 0, isAutocompleteSearch: true });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -275,17 +117,16 @@ export class SearchMapComponent extends Component {
|
|||
className,
|
||||
rootClassName,
|
||||
reusableContainerClassName,
|
||||
bounds,
|
||||
center,
|
||||
location,
|
||||
listings: originalListings,
|
||||
mapRootClassName,
|
||||
onCloseAsModal,
|
||||
onIdle,
|
||||
onMapMoveEnd,
|
||||
zoom,
|
||||
mapsConfig,
|
||||
activeListingId,
|
||||
} = this.props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const mapClasses = mapRootClassName || css.mapRoot;
|
||||
|
||||
const listingsWithLocation = originalListings.filter(l => !!l.attributes.geolocation);
|
||||
const listings = mapsConfig.fuzzy.enabled
|
||||
|
|
@ -293,45 +134,64 @@ export class SearchMapComponent extends Component {
|
|||
: listingsWithLocation;
|
||||
const infoCardOpen = this.state.infoCardOpen;
|
||||
|
||||
const isMapsLibLoaded = typeof window !== 'undefined' && window.google && window.google.maps;
|
||||
|
||||
const forceUpdateHandler = () => {
|
||||
this.mapReattachmentCount += 1;
|
||||
// Update global reattachement count
|
||||
window.mapReattachmentCount += 1;
|
||||
// Initiate rerendering
|
||||
this.setState({ mapReattachmentCount: window.mapReattachmentCount });
|
||||
};
|
||||
|
||||
// container element listens clicks so that opened SearchMapInfoCard can be closed
|
||||
/* eslint-disable jsx-a11y/no-static-element-interactions */
|
||||
return isMapsLibLoaded ? (
|
||||
<ReusableMapContainer className={reusableContainerClassName} onReattach={forceUpdateHandler}>
|
||||
<MapWithGoogleMap
|
||||
containerElement={<div className={classes} onClick={this.onMapClicked} />}
|
||||
mapElement={<div className={mapClasses} />}
|
||||
// When changing from default map provider to Google Maps, you should use the following
|
||||
// component instead of SearchMapWithMapbox:
|
||||
//
|
||||
// <SearchMapWithGoogleMap
|
||||
// containerElement={
|
||||
// <div id="search-map-container" className={classes} onClick={this.onMapClicked} />
|
||||
// }
|
||||
// mapElement={<div className={mapRootClassName || css.mapRoot} />}
|
||||
// bounds={bounds}
|
||||
// center={center}
|
||||
// location={location}
|
||||
// infoCardOpen={infoCardOpen}
|
||||
// listings={listings}
|
||||
// activeListingId={activeListingId}
|
||||
// mapComponentRefreshToken={this.state.mapReattachmentCount}
|
||||
// createURLToListing={this.createURLToListing}
|
||||
// onListingClicked={this.onListingClicked}
|
||||
// onListingInfoCardClicked={this.onListingInfoCardClicked}
|
||||
// onMapLoad={this.onMapLoadHandler}
|
||||
// onMapMoveEnd={onMapMoveEnd}
|
||||
// zoom={zoom}
|
||||
// />
|
||||
|
||||
return isMapsLibLoaded() ? (
|
||||
<ReusableMapContainer
|
||||
className={reusableContainerClassName}
|
||||
reusableMapHiddenHandle={REUSABLE_MAP_HIDDEN_HANDLE}
|
||||
onReattach={forceUpdateHandler}
|
||||
>
|
||||
<SearchMapWithMapbox
|
||||
className={classes}
|
||||
bounds={bounds}
|
||||
center={center}
|
||||
location={location}
|
||||
infoCardOpen={infoCardOpen}
|
||||
listings={listings}
|
||||
activeListingId={activeListingId}
|
||||
infoCardOpen={infoCardOpen}
|
||||
mapComponentRefreshToken={this.state.mapReattachmentCount}
|
||||
createURLToListing={this.createURLToListing}
|
||||
onListingClicked={this.onListingClicked}
|
||||
onListingInfoCardClicked={this.onListingInfoCardClicked}
|
||||
createURLToListing={this.createURLToListing}
|
||||
onMapLoad={this.onMapLoadHandler}
|
||||
onIdle={() => {
|
||||
if (this.googleMap) {
|
||||
onIdle(this.googleMap);
|
||||
}
|
||||
}}
|
||||
onCloseAsModal={() => {
|
||||
if (onCloseAsModal) {
|
||||
onCloseAsModal();
|
||||
}
|
||||
}}
|
||||
onClick={this.onMapClicked}
|
||||
onMapMoveEnd={onMapMoveEnd}
|
||||
zoom={zoom}
|
||||
mapComponentRefreshToken={this.mapReattachmentCount}
|
||||
reusableMapHiddenHandle={REUSABLE_MAP_HIDDEN_HANDLE}
|
||||
/>
|
||||
</ReusableMapContainer>
|
||||
) : (
|
||||
<div className={classes} />
|
||||
);
|
||||
/* eslint-enable jsx-a11y/no-static-element-interactions */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,11 +201,10 @@ SearchMapComponent.defaultProps = {
|
|||
mapRootClassName: null,
|
||||
reusableContainerClassName: null,
|
||||
bounds: null,
|
||||
center: new sdkTypes.LatLng(0, 0),
|
||||
center: null,
|
||||
activeListingId: null,
|
||||
listings: [],
|
||||
onCloseAsModal: null,
|
||||
useLocationSearchBounds: true,
|
||||
zoom: 11,
|
||||
mapsConfig: config.maps,
|
||||
};
|
||||
|
|
@ -357,11 +216,13 @@ SearchMapComponent.propTypes = {
|
|||
reusableContainerClassName: string,
|
||||
bounds: propTypes.latlngBounds,
|
||||
center: propTypes.latlng,
|
||||
location: shape({
|
||||
search: string.isRequired,
|
||||
}).isRequired,
|
||||
activeListingId: propTypes.uuid,
|
||||
listings: arrayOf(propTypes.listing),
|
||||
onCloseAsModal: func,
|
||||
onIdle: func.isRequired,
|
||||
useLocationSearchBounds: bool, // eslint-disable-line react/no-unused-prop-types
|
||||
onMapMoveEnd: func.isRequired,
|
||||
zoom: number,
|
||||
mapsConfig: object,
|
||||
|
||||
|
|
@ -373,4 +234,7 @@ SearchMapComponent.propTypes = {
|
|||
|
||||
const SearchMap = withRouter(SearchMapComponent);
|
||||
|
||||
SearchMap.getMapBounds = getMapBounds;
|
||||
SearchMap.getMapCenter = getMapCenter;
|
||||
|
||||
export default SearchMap;
|
||||
|
|
|
|||
463
src/components/SearchMap/SearchMapWithGoogleMap.js
Normal file
463
src/components/SearchMap/SearchMapWithGoogleMap.js
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import React, { Component } from 'react';
|
||||
import { arrayOf, func, node, number, oneOfType, shape, string } from 'prop-types';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import { withGoogleMap, GoogleMap, OverlayView } from 'react-google-maps';
|
||||
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import { parse } from '../../util/urlHelpers';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { ensureListing } from '../../util/data';
|
||||
import { sdkBoundsToFixedCoordinates, hasSameSDKBounds } from '../../util/maps';
|
||||
import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components';
|
||||
|
||||
import { groupedByCoordinates, reducedToArray } from './SearchMap.helpers.js';
|
||||
|
||||
export const LABEL_HANDLE = 'SearchMapLabel';
|
||||
export const INFO_CARD_HANDLE = 'SearchMapInfoCard';
|
||||
const BOUNDS_FIXED_PRECISION = 8;
|
||||
|
||||
const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes;
|
||||
|
||||
/**
|
||||
* Fit part of map (descriped with bounds) to visible map-viewport
|
||||
*
|
||||
* @param {Object} map - map that needs to be centered with given bounds
|
||||
* @param {SDK.LatLngBounds} bounds - the area that needs to be visible when map loads.
|
||||
*/
|
||||
export const fitMapToBounds = (map, bounds, options) => {
|
||||
const { padding } = options;
|
||||
|
||||
const { ne, sw } = bounds || {};
|
||||
// map bounds as string literal for google.maps
|
||||
const mapBounds = bounds ? { north: ne.lat, east: ne.lng, south: sw.lat, west: sw.lng } : null;
|
||||
|
||||
// If bounds are given, use it (defaults to center & zoom).
|
||||
if (map && mapBounds) {
|
||||
if (padding == null) {
|
||||
map.fitBounds(mapBounds);
|
||||
} else {
|
||||
map.fitBounds(mapBounds, padding);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Google formatted LatLng object to Sharetribe SDK's LatLng coordinate format
|
||||
*
|
||||
* @param {LatLng} googleLatLng - Google Maps LatLng
|
||||
*
|
||||
* @return {SDKLatLng} - Converted latLng coordinate
|
||||
*/
|
||||
export const googleLatLngToSDKLatLng = googleLatLng => {
|
||||
if (!googleLatLng) {
|
||||
return null;
|
||||
}
|
||||
return new SDKLatLng(googleLatLng.lat(), googleLatLng.lng());
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Google formatted bounds object to Sharetribe SDK's bounds format
|
||||
*
|
||||
* @param {LatLngBounds} googleBounds - Google Maps LatLngBounds
|
||||
*
|
||||
* @return {SDKLatLngBounds} - Converted bounds
|
||||
*/
|
||||
export const googleBoundsToSDKBounds = googleBounds => {
|
||||
if (!googleBounds) {
|
||||
return null;
|
||||
}
|
||||
const ne = googleBounds.getNorthEast();
|
||||
const sw = googleBounds.getSouthWest();
|
||||
return new SDKLatLngBounds(new SDKLatLng(ne.lat(), ne.lng()), new SDKLatLng(sw.lat(), sw.lng()));
|
||||
};
|
||||
|
||||
export const getMapBounds = map => googleBoundsToSDKBounds(map.getBounds());
|
||||
export const getMapCenter = map => googleLatLngToSDKLatLng(map.getCenter());
|
||||
|
||||
/**
|
||||
* Check if map library is loaded
|
||||
*/
|
||||
export const isMapsLibLoaded = () =>
|
||||
typeof window !== 'undefined' && window.google && window.google.maps;
|
||||
|
||||
/**
|
||||
* FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
|
||||
* Override draw function to catch errors with map panes being undefined to prevent console errors
|
||||
* https://github.com/tomchentw/react-google-maps/issues/482
|
||||
*/
|
||||
class CustomOverlayView extends OverlayView {
|
||||
draw() {
|
||||
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
|
||||
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
|
||||
// Add conditional to ensure panes and container exist before drawing
|
||||
if (mapPanes && this.containerElement) {
|
||||
super.draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Center label so that caret is pointing to correct pixel.
|
||||
* (vertical positioning: height + arrow)
|
||||
*/
|
||||
const getPixelPositionOffset = (width, height) => {
|
||||
return { x: -1 * (width / 2), y: -1 * (height + 3) };
|
||||
};
|
||||
|
||||
/**
|
||||
* GoogleMaps need to use Google specific OverlayView components and therefore we need to
|
||||
* reduce flickering / rerendering of these overlays through 'shouldComponentUpdate'
|
||||
*/
|
||||
class SearchMapPriceLabelWithOverlay extends Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const currentListing = ensureListing(this.props.listing);
|
||||
const nextListing = ensureListing(nextProps.listing);
|
||||
const isSameListing = currentListing.id.uuid === nextListing.id.uuid;
|
||||
const hasSamePrice = currentListing.attributes.price === nextListing.attributes.price;
|
||||
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
|
||||
const hasSameRefreshToken =
|
||||
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
|
||||
|
||||
return !(isSameListing && hasSamePrice && hasSameActiveStatus && hasSameRefreshToken);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
position,
|
||||
mapPaneName,
|
||||
isActive,
|
||||
className,
|
||||
listing,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken,
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<CustomOverlayView
|
||||
position={position}
|
||||
mapPaneName={mapPaneName}
|
||||
getPixelPositionOffset={getPixelPositionOffset}
|
||||
>
|
||||
<SearchMapPriceLabel
|
||||
isActive={isActive}
|
||||
className={className}
|
||||
listing={listing}
|
||||
onListingClicked={onListingClicked}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
/>
|
||||
</CustomOverlayView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GoogleMaps need to use Google specific OverlayView components and therefore we need to
|
||||
* reduce flickering / rerendering of these overlays through 'shouldComponentUpdate'
|
||||
*/
|
||||
class SearchMapGroupLabelWithOverlay extends Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const hasSameAmountOfListings = nextProps.listings.length === this.props.listings.length;
|
||||
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
|
||||
const hasSameRefreshToken =
|
||||
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
|
||||
|
||||
return !(hasSameAmountOfListings && hasSameActiveStatus && hasSameRefreshToken);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
position,
|
||||
mapPaneName,
|
||||
isActive,
|
||||
className,
|
||||
listings,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken,
|
||||
} = this.props;
|
||||
return (
|
||||
<CustomOverlayView
|
||||
position={position}
|
||||
mapPaneName={mapPaneName}
|
||||
getPixelPositionOffset={getPixelPositionOffset}
|
||||
>
|
||||
<SearchMapGroupLabel
|
||||
isActive={isActive}
|
||||
className={className}
|
||||
listings={listings}
|
||||
onListingClicked={onListingClicked}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
/>
|
||||
</CustomOverlayView>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const priceLabelsInLocations = (
|
||||
listings,
|
||||
activeListingId,
|
||||
infoCardOpen,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken
|
||||
) => {
|
||||
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
|
||||
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
|
||||
const isActive = activeListingId
|
||||
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
|
||||
: false;
|
||||
|
||||
// If location contains only one listing, print price label
|
||||
if (listingArr.length === 1) {
|
||||
const listing = listingArr[0];
|
||||
const infoCardOpenIds = Array.isArray(infoCardOpen) ? infoCardOpen.map(l => l.id.uuid) : [];
|
||||
|
||||
// if the listing is open, don't print price label
|
||||
if (infoCardOpen != null && infoCardOpenIds.includes(listing.id.uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const { geolocation } = listing.attributes;
|
||||
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
|
||||
|
||||
return (
|
||||
<SearchMapPriceLabelWithOverlay
|
||||
key={listing.id.uuid}
|
||||
position={latLngLiteral}
|
||||
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
|
||||
isActive={isActive}
|
||||
className={LABEL_HANDLE}
|
||||
listing={listing}
|
||||
onListingClicked={onListingClicked}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const firstListing = ensureListing(listingArr[0]);
|
||||
const geolocation = firstListing.attributes.geolocation;
|
||||
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
|
||||
|
||||
return (
|
||||
<SearchMapGroupLabelWithOverlay
|
||||
key={listingArr[0].id.uuid}
|
||||
position={latLngLiteral}
|
||||
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
|
||||
isActive={isActive}
|
||||
className={LABEL_HANDLE}
|
||||
listings={listingArr}
|
||||
onListingClicked={onListingClicked}
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
/>
|
||||
);
|
||||
});
|
||||
return priceLabels;
|
||||
};
|
||||
|
||||
const infoCardComponent = (
|
||||
infoCardOpen,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
mapComponentRefreshToken
|
||||
) => {
|
||||
const listingsArray = Array.isArray(infoCardOpen) ? infoCardOpen : [infoCardOpen];
|
||||
|
||||
if (!infoCardOpen) {
|
||||
return null;
|
||||
}
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const firstListing = ensureListing(listingsArray[0]);
|
||||
const geolocation = firstListing.attributes.geolocation;
|
||||
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
|
||||
|
||||
return (
|
||||
<CustomOverlayView
|
||||
key={listingsArray[0].id.uuid}
|
||||
position={latLngLiteral}
|
||||
mapPaneName={OverlayView.FLOAT_PANE}
|
||||
getPixelPositionOffset={getPixelPositionOffset}
|
||||
styles={{ zIndex: 1 }}
|
||||
>
|
||||
<SearchMapInfoCard
|
||||
mapComponentRefreshToken={mapComponentRefreshToken}
|
||||
className={INFO_CARD_HANDLE}
|
||||
listings={listingsArray}
|
||||
onListingInfoCardClicked={onListingInfoCardClicked}
|
||||
createURLToListing={createURLToListing}
|
||||
/>
|
||||
</CustomOverlayView>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* MapWithGoogleMap uses withGoogleMap HOC.
|
||||
* It handles some of the google map initialization states.
|
||||
*/
|
||||
const MapWithGoogleMap = withGoogleMap(props => {
|
||||
const {
|
||||
center,
|
||||
infoCardOpen,
|
||||
listings,
|
||||
activeListingId,
|
||||
createURLToListing,
|
||||
onListingClicked,
|
||||
onListingInfoCardClicked,
|
||||
onIdle,
|
||||
onMapLoad,
|
||||
zoom,
|
||||
mapComponentRefreshToken,
|
||||
} = props;
|
||||
|
||||
const controlPosition =
|
||||
typeof window !== 'undefined' && typeof window.google !== 'undefined'
|
||||
? window.google.maps.ControlPosition.LEFT_TOP
|
||||
: 5;
|
||||
|
||||
const priceLabels = priceLabelsInLocations(
|
||||
listings,
|
||||
activeListingId,
|
||||
infoCardOpen,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken
|
||||
);
|
||||
const infoCard = infoCardComponent(
|
||||
infoCardOpen,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
mapComponentRefreshToken
|
||||
);
|
||||
|
||||
return (
|
||||
<GoogleMap
|
||||
defaultZoom={zoom}
|
||||
defaultCenter={center}
|
||||
options={{
|
||||
// Disable all controls except zoom
|
||||
mapTypeControl: false,
|
||||
scrollwheel: false,
|
||||
fullscreenControl: false,
|
||||
clickableIcons: false,
|
||||
streetViewControl: false,
|
||||
|
||||
// When infoCard is open, we can't differentiate double click on top of card vs map.
|
||||
disableDoubleClickZoom: !!infoCardOpen,
|
||||
|
||||
zoomControlOptions: {
|
||||
position: controlPosition,
|
||||
},
|
||||
}}
|
||||
ref={onMapLoad}
|
||||
onIdle={onIdle}
|
||||
>
|
||||
{priceLabels}
|
||||
{infoCard}
|
||||
</GoogleMap>
|
||||
);
|
||||
});
|
||||
|
||||
class SearchMapWithGoogleMap extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.map = null;
|
||||
this.viewportBounds = null;
|
||||
|
||||
this.onMapLoad = this.onMapLoad.bind(this);
|
||||
this.onIdle = this.onIdle.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!isEqual(this.props.location, nextProps.location)) {
|
||||
// If no mapSearch url parameter is given, this is original location search
|
||||
const { mapSearch } = parse(nextProps.location.search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
if (!mapSearch) {
|
||||
this.viewportBounds = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.map) {
|
||||
const currentBounds = getMapBounds(this.map);
|
||||
|
||||
// Do not call fitMapToBounds if bounds are the same.
|
||||
// Our bounds are viewport bounds, and fitBounds will try to add margins around those bounds
|
||||
// that would result to zoom-loop (bound change -> fitmap -> bounds change -> ...)
|
||||
if (!isEqual(nextProps.bounds, currentBounds) && !this.viewportBounds) {
|
||||
fitMapToBounds(this.map, nextProps.bounds, { padding: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMapLoad(map) {
|
||||
this.map = map;
|
||||
this.props.onMapLoad(map);
|
||||
}
|
||||
|
||||
onIdle(e) {
|
||||
if (this.map) {
|
||||
// Let's try to find the map container element
|
||||
const mapContainer = this.props.containerElement.props.id
|
||||
? document.getElementById(this.props.containerElement.props.id)
|
||||
: null;
|
||||
|
||||
// If reusableMapHiddenHandle is given and parent element has that class,
|
||||
// we don't listen moveend events.
|
||||
// This fixes mobile Chrome bug that sends map events to invisible map components.
|
||||
const isHiddenByReusableMap =
|
||||
this.props.reusableMapHiddenHandle &&
|
||||
mapContainer &&
|
||||
mapContainer.parentElement.classList.contains(this.props.reusableMapHiddenHandle);
|
||||
|
||||
if (!isHiddenByReusableMap) {
|
||||
const viewportMapBounds = getMapBounds(this.map);
|
||||
const viewportMapCenter = getMapCenter(this.map);
|
||||
const viewportBounds = viewportMapBounds
|
||||
? sdkBoundsToFixedCoordinates(viewportMapBounds, BOUNDS_FIXED_PRECISION)
|
||||
: null;
|
||||
|
||||
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
|
||||
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
|
||||
const viewportBoundsChanged =
|
||||
this.viewportBounds &&
|
||||
viewportBounds &&
|
||||
!hasSameSDKBounds(this.viewportBounds, viewportBounds);
|
||||
|
||||
this.props.onMapMoveEnd(viewportBoundsChanged, { viewportBounds, viewportMapCenter });
|
||||
this.viewportBounds = viewportBounds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { onMapLoad, onMapMoveEnd, ...rest } = this.props;
|
||||
return <MapWithGoogleMap onMapLoad={this.onMapLoad} onIdle={this.onIdle} {...rest} />;
|
||||
}
|
||||
}
|
||||
|
||||
SearchMapWithGoogleMap.defaultProps = {
|
||||
center: new sdkTypes.LatLng(0, 0),
|
||||
infoCardOpen: null,
|
||||
listings: [],
|
||||
activeListingId: null,
|
||||
zoom: 11,
|
||||
reusableMapHiddenHandle: null,
|
||||
};
|
||||
|
||||
SearchMapWithGoogleMap.propTypes = {
|
||||
containerElement: node.isRequired,
|
||||
center: propTypes.latlng,
|
||||
location: shape({
|
||||
search: string.isRequired,
|
||||
}).isRequired,
|
||||
infoCardOpen: oneOfType([propTypes.listing, arrayOf(propTypes.listing)]),
|
||||
listings: arrayOf(propTypes.listing),
|
||||
activeListingId: propTypes.uuid,
|
||||
|
||||
onMapMoveEnd: func.isRequired,
|
||||
onMapLoad: func.isRequired,
|
||||
zoom: number,
|
||||
reusableMapHiddenHandle: string,
|
||||
};
|
||||
|
||||
export default SearchMapWithGoogleMap;
|
||||
25
src/components/SearchMap/SearchMapWithMapbox.css
Normal file
25
src/components/SearchMap/SearchMapWithMapbox.css
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
.fullArea {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.activeLabel {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.labelContainer {
|
||||
&:hover {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.infoCardContainer {
|
||||
z-index: 1;
|
||||
}
|
||||
518
src/components/SearchMap/SearchMapWithMapbox.js
Normal file
518
src/components/SearchMap/SearchMapWithMapbox.js
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { arrayOf, func, node, number, shape, string } from 'prop-types';
|
||||
import differenceBy from 'lodash/differenceBy';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import classNames from 'classnames';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import { parse } from '../../util/urlHelpers';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { ensureListing } from '../../util/data';
|
||||
import { sdkBoundsToFixedCoordinates, hasSameSDKBounds } from '../../util/maps';
|
||||
import { SearchMapInfoCard, SearchMapPriceLabel, SearchMapGroupLabel } from '../../components';
|
||||
|
||||
import { groupedByCoordinates, reducedToArray } from './SearchMap.helpers.js';
|
||||
import css from './SearchMapWithMapbox.css';
|
||||
|
||||
export const LABEL_HANDLE = 'SearchMapLabel';
|
||||
export const INFO_CARD_HANDLE = 'SearchMapInfoCard';
|
||||
export const SOURCE_AUTOCOMPLETE = 'autocomplete';
|
||||
const BOUNDS_FIXED_PRECISION = 8;
|
||||
|
||||
const { LatLng: SDKLatLng, LatLngBounds: SDKLatLngBounds } = sdkTypes;
|
||||
|
||||
/**
|
||||
* Fit part of map (descriped with bounds) to visible map-viewport
|
||||
*
|
||||
* @param {Object} map - map that needs to be centered with given bounds
|
||||
* @param {SDK.LatLngBounds} bounds - the area that needs to be visible when map loads.
|
||||
*/
|
||||
export const fitMapToBounds = (map, bounds, options) => {
|
||||
const { padding = 0, isAutocompleteSearch = false } = options;
|
||||
|
||||
// map bounds as string literal for google.maps
|
||||
const mapBounds = sdkBoundsToMapboxBounds(bounds);
|
||||
const paddingOptionMaybe = padding == null ? { padding } : {};
|
||||
const eventData = isAutocompleteSearch ? { searchSource: SOURCE_AUTOCOMPLETE } : {};
|
||||
|
||||
// If bounds are given, use it (defaults to center & zoom).
|
||||
if (map && mapBounds) {
|
||||
map.fitBounds(mapBounds, { ...paddingOptionMaybe, linear: true, duration: 0 }, eventData);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Mapbox formatted LatLng object to Sharetribe SDK's LatLng coordinate format
|
||||
* Longitudes > 180 and < -180 are converted to the correct corresponding value
|
||||
* between -180 and 180.
|
||||
*
|
||||
* @param {LngLat} mapboxLngLat - Mapbox LngLat
|
||||
*
|
||||
* @return {SDKLatLng} - Converted latLng coordinate
|
||||
*/
|
||||
export const mapboxLngLatToSDKLatLng = lngLat => {
|
||||
const mapboxLng = lngLat.lng;
|
||||
|
||||
// For bounding boxes that overlap the antimeridian Mapbox sometimes gives
|
||||
// longitude values outside -180 and 180 degrees.Those values are converted
|
||||
// so that longitude is always between -180 and 180.
|
||||
const lng = mapboxLng > 180 ? mapboxLng - 360 : mapboxLng < -180 ? mapboxLng + 360 : mapboxLng;
|
||||
|
||||
return new SDKLatLng(lngLat.lat, lng);
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Mapbox formatted bounds object to Sharetribe SDK's bounds format
|
||||
*
|
||||
* @param {LngLatBounds} mapboxBounds - Mapbox LngLatBounds
|
||||
*
|
||||
* @return {SDKLatLngBounds} - Converted bounds
|
||||
*/
|
||||
export const mapboxBoundsToSDKBounds = mapboxBounds => {
|
||||
if (!mapboxBounds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ne = mapboxBounds.getNorthEast();
|
||||
const sw = mapboxBounds.getSouthWest();
|
||||
return new SDKLatLngBounds(mapboxLngLatToSDKLatLng(ne), mapboxLngLatToSDKLatLng(sw));
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert sdk bounds that overlap the antimeridian into values that can
|
||||
* be passed to Mapbox. This is achieved by converting the SW longitude into
|
||||
* a value less than -180 that flows over the antimeridian.
|
||||
*
|
||||
* @param {SDKLatLng} bounds - bounds passed to the map
|
||||
*
|
||||
* @return {LngLatBoundsLike} a bounding box that is compatible with Mapbox
|
||||
*/
|
||||
const sdkBoundsToMapboxBounds = bounds => {
|
||||
if (!bounds) {
|
||||
return null;
|
||||
}
|
||||
const { ne, sw } = bounds;
|
||||
|
||||
// if sw lng is > ne lng => the bounds overlap antimeridian
|
||||
// => flip the nw lng to the negative side so that the value
|
||||
// is less than -180
|
||||
const swLng = sw.lng > ne.lng ? -360 + sw.lng : sw.lng;
|
||||
|
||||
return [[swLng, sw.lat], [ne.lng, ne.lat]];
|
||||
};
|
||||
|
||||
/**
|
||||
* Return map bounds as SDKBounds
|
||||
*
|
||||
* @param {Mapbox} map - Mapbox map from where the bounds are asked
|
||||
*
|
||||
* @return {SDKLatLngBounds} - Converted bounds of given map
|
||||
*/
|
||||
export const getMapBounds = map => mapboxBoundsToSDKBounds(map.getBounds());
|
||||
|
||||
/**
|
||||
* Return map center as SDKLatLng
|
||||
*
|
||||
* @param {Mapbox} map - Mapbox map from where the center is asked
|
||||
*
|
||||
* @return {SDKLatLng} - Converted center of given map
|
||||
*/
|
||||
export const getMapCenter = map => mapboxLngLatToSDKLatLng(map.getCenter());
|
||||
|
||||
/**
|
||||
* Check if map library is loaded
|
||||
*/
|
||||
export const isMapsLibLoaded = () =>
|
||||
typeof window !== 'undefined' && window.mapboxgl && window.mapboxgl.accessToken;
|
||||
|
||||
/**
|
||||
* Return price labels grouped by listing locations.
|
||||
* This is a helper function for SearchMapWithMapbox component.
|
||||
*/
|
||||
const priceLabelsInLocations = (
|
||||
listings,
|
||||
activeListingId,
|
||||
infoCardOpen,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken
|
||||
) => {
|
||||
const listingArraysInLocations = reducedToArray(groupedByCoordinates(listings));
|
||||
const priceLabels = listingArraysInLocations.reverse().map(listingArr => {
|
||||
const isActive = activeListingId
|
||||
? !!listingArr.find(l => activeListingId.uuid === l.id.uuid)
|
||||
: false;
|
||||
|
||||
// If location contains only one listing, print price label
|
||||
if (listingArr.length === 1) {
|
||||
const listing = listingArr[0];
|
||||
const infoCardOpenIds = Array.isArray(infoCardOpen)
|
||||
? infoCardOpen.map(l => l.id.uuid)
|
||||
: infoCardOpen
|
||||
? [infoCardOpen.id.uuid]
|
||||
: [];
|
||||
|
||||
// if the listing is open, don't print price label
|
||||
if (infoCardOpen != null && infoCardOpenIds.includes(listing.id.uuid)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const { geolocation } = listing.attributes;
|
||||
|
||||
const key = listing.id.uuid;
|
||||
return {
|
||||
markerId: `price_${key}`,
|
||||
location: geolocation,
|
||||
type: 'price',
|
||||
componentProps: {
|
||||
key,
|
||||
isActive,
|
||||
className: LABEL_HANDLE,
|
||||
listing,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const firstListing = ensureListing(listingArr[0]);
|
||||
const geolocation = firstListing.attributes.geolocation;
|
||||
|
||||
const key = listingArr[0].id.uuid;
|
||||
return {
|
||||
markerId: `group_${key}`,
|
||||
location: geolocation,
|
||||
type: 'group',
|
||||
componentProps: {
|
||||
key,
|
||||
isActive,
|
||||
className: LABEL_HANDLE,
|
||||
listings: listingArr,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken,
|
||||
},
|
||||
};
|
||||
});
|
||||
return priceLabels;
|
||||
};
|
||||
|
||||
/**
|
||||
* Return info card. This is a helper function for SearchMapWithMapbox component.
|
||||
*/
|
||||
const infoCardComponent = (
|
||||
infoCardOpen,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
mapComponentRefreshToken
|
||||
) => {
|
||||
const listingsArray = Array.isArray(infoCardOpen) ? infoCardOpen : [infoCardOpen];
|
||||
|
||||
if (!infoCardOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstListing = ensureListing(listingsArray[0]);
|
||||
const key = firstListing.id.uuid;
|
||||
const geolocation = firstListing.attributes.geolocation;
|
||||
|
||||
return {
|
||||
markerId: `infoCard_${key}`,
|
||||
location: geolocation,
|
||||
componentProps: {
|
||||
key,
|
||||
mapComponentRefreshToken,
|
||||
className: INFO_CARD_HANDLE,
|
||||
listings: listingsArray,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* SearchMap component using Mapbox as map provider
|
||||
*/
|
||||
class SearchMapWithMapbox extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.map = typeof window !== 'undefined' && window.mapboxMap ? window.mapboxMap : null;
|
||||
this.currentMarkers = [];
|
||||
this.currentInfoCard = null;
|
||||
this.state = { mapContainer: null, isMapReady: false };
|
||||
this.viewportBounds = null;
|
||||
|
||||
this.onMount = this.onMount.bind(this);
|
||||
this.onMoveend = this.onMoveend.bind(this);
|
||||
this.initializeMap = this.initializeMap.bind(this);
|
||||
this.handleDoubleClickOnInfoCard = this.handleDoubleClickOnInfoCard.bind(this);
|
||||
this.handleMobilePinchZoom = this.handleMobilePinchZoom.bind(this);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!isEqual(this.props.location, nextProps.location)) {
|
||||
// If no mapSearch url parameter is given, this is original location search
|
||||
const { mapSearch } = parse(nextProps.location.search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
if (!mapSearch) {
|
||||
this.viewportBounds = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.map) {
|
||||
const currentBounds = getMapBounds(this.map);
|
||||
|
||||
// Do not call fitMapToBounds if bounds are the same.
|
||||
// Our bounds are viewport bounds, and fitBounds will try to add margins around those bounds
|
||||
// that would result to zoom-loop (bound change -> fitmap -> bounds change -> ...)
|
||||
if (!isEqual(nextProps.bounds, currentBounds) && !this.viewportBounds) {
|
||||
fitMapToBounds(this.map, nextProps.bounds, { padding: 0, isAutocompleteSearch: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (!this.map && this.state.mapContainer) {
|
||||
this.initializeMap();
|
||||
|
||||
/* Notify parent component that Mapbox map is loaded */
|
||||
this.props.onMapLoad(this.map);
|
||||
} else if (prevProps.mapComponentRefreshToken !== this.props.mapComponentRefreshToken) {
|
||||
/* Notify parent component that Mapbox map is loaded */
|
||||
this.props.onMapLoad(this.map);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.currentInfoCard.markerContainer.removeEventListener(
|
||||
'dblclick',
|
||||
this.handleDoubleClickOnInfoCard
|
||||
);
|
||||
document.removeEventListener('gesturestart', this.handleMobilePinchZoom, false);
|
||||
document.removeEventListener('gesturechange', this.handleMobilePinchZoom, false);
|
||||
document.removeEventListener('gestureend', this.handleMobilePinchZoom, false);
|
||||
}
|
||||
|
||||
onMount(element) {
|
||||
// This prevents pinch zoom to affect whole page on mobile Safari.
|
||||
document.addEventListener('gesturestart', this.handleMobilePinchZoom, false);
|
||||
document.addEventListener('gesturechange', this.handleMobilePinchZoom, false);
|
||||
document.addEventListener('gestureend', this.handleMobilePinchZoom, false);
|
||||
|
||||
this.setState({ mapContainer: element });
|
||||
}
|
||||
|
||||
onMoveend(e) {
|
||||
if (this.map) {
|
||||
// If reusableMapHiddenHandle is given and parent element has that class,
|
||||
// we don't listen moveend events.
|
||||
// This fixes mobile Chrome bug that sends map events to invisible map components.
|
||||
const isHiddenByReusableMap =
|
||||
this.props.reusableMapHiddenHandle &&
|
||||
this.state.mapContainer.parentElement.classList.contains(
|
||||
this.props.reusableMapHiddenHandle
|
||||
);
|
||||
if (!isHiddenByReusableMap) {
|
||||
const viewportMapBounds = getMapBounds(this.map);
|
||||
const viewportMapCenter = getMapCenter(this.map);
|
||||
const viewportBounds = sdkBoundsToFixedCoordinates(
|
||||
viewportMapBounds,
|
||||
BOUNDS_FIXED_PRECISION
|
||||
);
|
||||
|
||||
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
|
||||
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
|
||||
const viewportBoundsChanged =
|
||||
this.viewportBounds && !hasSameSDKBounds(this.viewportBounds, viewportBounds);
|
||||
|
||||
this.props.onMapMoveEnd(viewportBoundsChanged, { viewportBounds, viewportMapCenter });
|
||||
this.viewportBounds = viewportBounds;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
initializeMap() {
|
||||
const { offsetHeight, offsetWidth } = this.state.mapContainer;
|
||||
const hasDimensions = offsetHeight > 0 && offsetWidth > 0;
|
||||
if (hasDimensions) {
|
||||
this.map = new window.mapboxgl.Map({
|
||||
container: this.state.mapContainer,
|
||||
style: 'mapbox://styles/mapbox/streets-v10',
|
||||
scrollZoom: false,
|
||||
});
|
||||
window.mapboxMap = this.map;
|
||||
|
||||
var nav = new window.mapboxgl.NavigationControl({ showCompass: false });
|
||||
this.map.addControl(nav, 'top-left');
|
||||
|
||||
this.map.on('moveend', this.onMoveend);
|
||||
|
||||
// Introduce rerendering after map is ready (to include labels),
|
||||
// but keep the map out of state life cycle.
|
||||
this.setState({ isMapReady: true });
|
||||
}
|
||||
}
|
||||
|
||||
handleMobilePinchZoom(e) {
|
||||
e.preventDefault();
|
||||
// A hack to prevent pinch zoom gesture in mobile Safari
|
||||
// Otherwise, pinch zoom would zoom both map and the document.
|
||||
document.body.style.zoom = 0.99;
|
||||
}
|
||||
|
||||
handleDoubleClickOnInfoCard(e) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
listings,
|
||||
activeListingId,
|
||||
infoCardOpen,
|
||||
onListingClicked,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
mapComponentRefreshToken,
|
||||
} = this.props;
|
||||
|
||||
if (this.map) {
|
||||
// Create markers out of price labels and grouped labels
|
||||
const labels = priceLabelsInLocations(
|
||||
listings,
|
||||
activeListingId,
|
||||
infoCardOpen,
|
||||
onListingClicked,
|
||||
mapComponentRefreshToken
|
||||
);
|
||||
|
||||
// If map has moved or info card opened, unnecessary markers need to be removed
|
||||
const removableMarkers = differenceBy(this.currentMarkers, labels, 'markerId');
|
||||
removableMarkers.forEach(rm => rm.marker.remove());
|
||||
|
||||
// Helper function to create markers to given container
|
||||
const createMarker = (data, markerContainer) =>
|
||||
new window.mapboxgl.Marker(markerContainer, { anchor: 'bottom' })
|
||||
.setLngLat([data.location.lng, data.location.lat])
|
||||
.addTo(this.map);
|
||||
|
||||
// SearchMapPriceLabel and SearchMapGroupLabel:
|
||||
// create a new marker or use existing one if markerId is among previously rendered markers
|
||||
this.currentMarkers = labels.filter(v => v != null).map(m => {
|
||||
const existingMarkerId = this.currentMarkers.findIndex(
|
||||
marker => m.markerId === marker.markerId && marker.marker
|
||||
);
|
||||
|
||||
if (existingMarkerId >= 0) {
|
||||
const { marker, markerContainer, ...rest } = this.currentMarkers[existingMarkerId];
|
||||
return { ...rest, ...m, markerContainer, marker };
|
||||
} else {
|
||||
const markerContainer = document.createElement('div');
|
||||
markerContainer.setAttribute('id', m.markerId);
|
||||
markerContainer.classList.add(css.labelContainer);
|
||||
const marker = createMarker(m, markerContainer);
|
||||
return { ...m, markerContainer, marker };
|
||||
}
|
||||
});
|
||||
|
||||
/* Create marker for SearchMapInfoCard component */
|
||||
if (infoCardOpen) {
|
||||
const infoCard = infoCardComponent(
|
||||
infoCardOpen,
|
||||
onListingInfoCardClicked,
|
||||
createURLToListing,
|
||||
mapComponentRefreshToken
|
||||
);
|
||||
|
||||
// marker container and its styles
|
||||
const infoCardContainer = document.createElement('div');
|
||||
infoCardContainer.setAttribute('id', infoCard.markerId);
|
||||
infoCardContainer.classList.add(css.infoCardContainer);
|
||||
infoCardContainer.addEventListener('dblclick', this.handleDoubleClickOnInfoCard, false);
|
||||
|
||||
this.currentInfoCard = {
|
||||
...infoCard,
|
||||
markerContainer: infoCardContainer,
|
||||
marker: infoCard ? createMarker(infoCard, infoCardContainer) : null,
|
||||
};
|
||||
} else {
|
||||
if (this.currentInfoCard) {
|
||||
this.currentInfoCard.markerContainer.removeEventListener(
|
||||
'dblclick',
|
||||
this.handleDoubleClickOnInfoCard
|
||||
);
|
||||
}
|
||||
this.currentInfoCard = null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="map"
|
||||
ref={this.onMount}
|
||||
className={classNames(className, css.fullArea)}
|
||||
onClick={this.props.onClick}
|
||||
>
|
||||
{this.currentMarkers.map(m => {
|
||||
// Remove existing activeLabel classes and add it only to the correct container
|
||||
m.markerContainer.classList.remove(css.activeLabel);
|
||||
if (activeListingId && activeListingId.uuid === m.componentProps.key) {
|
||||
m.markerContainer.classList.add(css.activeLabel);
|
||||
}
|
||||
|
||||
const isMapReadyForMarkers = this.map && m.markerContainer;
|
||||
// DOM node that should be used as portal's root
|
||||
const portalDOMContainer = isMapReadyForMarkers
|
||||
? document.getElementById(m.markerContainer.id)
|
||||
: null;
|
||||
|
||||
// Create component portals for correct marker containers
|
||||
if (isMapReadyForMarkers && m.type === 'price') {
|
||||
return ReactDOM.createPortal(
|
||||
<SearchMapPriceLabel {...m.componentProps} />,
|
||||
portalDOMContainer
|
||||
);
|
||||
} else if (isMapReadyForMarkers && m.type === 'group') {
|
||||
return ReactDOM.createPortal(
|
||||
<SearchMapGroupLabel {...m.componentProps} />,
|
||||
portalDOMContainer
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
{this.state.mapContainer && this.currentInfoCard
|
||||
? ReactDOM.createPortal(
|
||||
<SearchMapInfoCard {...this.currentInfoCard.componentProps} />,
|
||||
this.currentInfoCard.markerContainer
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
SearchMapWithMapbox.defaultProps = {
|
||||
center: null,
|
||||
priceLabels: [],
|
||||
infoCard: null,
|
||||
zoom: 11,
|
||||
reusableMapHiddenHandle: null,
|
||||
};
|
||||
|
||||
SearchMapWithMapbox.propTypes = {
|
||||
center: propTypes.latlng,
|
||||
location: shape({
|
||||
search: string.isRequired,
|
||||
}).isRequired,
|
||||
priceLabels: arrayOf(node),
|
||||
infoCard: node,
|
||||
onClick: func.isRequired,
|
||||
onMapMoveEnd: func.isRequired,
|
||||
onMapLoad: func.isRequired,
|
||||
zoom: number,
|
||||
reusableMapHiddenHandle: string,
|
||||
};
|
||||
|
||||
export default SearchMapWithMapbox;
|
||||
|
|
@ -1,64 +1,32 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { OverlayView } from 'react-google-maps';
|
||||
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
|
||||
import classNames from 'classnames';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { ensureListing } from '../../util/data';
|
||||
|
||||
import css from './SearchMapGroupLabel.css';
|
||||
|
||||
// FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
|
||||
// Override draw function to catch errors with map panes being undefined to prevent console errors
|
||||
// https://github.com/tomchentw/react-google-maps/issues/482
|
||||
class CustomOverlayView extends OverlayView {
|
||||
draw() {
|
||||
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
|
||||
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
|
||||
// Add conditional to ensure panes and container exist before drawing
|
||||
if (mapPanes && this.containerElement) {
|
||||
super.draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center label so that caret is pointing to correct pixel.
|
||||
// (vertical positioning: height + arrow) */
|
||||
const getPixelPositionOffset = (width, height) => {
|
||||
return { x: -1 * (width / 2), y: -1 * (height + 3) };
|
||||
};
|
||||
|
||||
class SearchMapGroupLabel extends Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const hasSameAmountOfListings = nextProps.listings.length === this.props.listings.length;
|
||||
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
|
||||
const hasSameRefreshToken =
|
||||
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
|
||||
|
||||
return !(hasSameAmountOfListings && hasSameActiveStatus);
|
||||
return !(hasSameAmountOfListings && hasSameActiveStatus && hasSameRefreshToken);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, rootClassName, listings, onListingClicked, isActive } = this.props;
|
||||
const firstListing = ensureListing(listings[0]);
|
||||
const geolocation = firstListing.attributes.geolocation;
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const countLabelClasses = classNames(css.details, { [css.detailsActive]: isActive });
|
||||
const caretClasses = classNames(css.caret, { [css.caretActive]: isActive });
|
||||
|
||||
return (
|
||||
<CustomOverlayView
|
||||
position={latLngLiteral}
|
||||
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
|
||||
getPixelPositionOffset={getPixelPositionOffset}
|
||||
>
|
||||
<button className={classes} onClick={() => onListingClicked(listings)}>
|
||||
<div className={css.caretShadow} />
|
||||
<div className={countLabelClasses}>{listings.length}</div>
|
||||
<div className={caretClasses} />
|
||||
</button>
|
||||
</CustomOverlayView>
|
||||
<button className={classes} onClick={() => onListingClicked(listings)}>
|
||||
<div className={css.caretShadow} />
|
||||
<div className={countLabelClasses}>{listings.length}</div>
|
||||
<div className={caretClasses} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { Component } from 'react';
|
||||
import { arrayOf, bool, func, string } from 'prop-types';
|
||||
import { OverlayView } from 'react-google-maps';
|
||||
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
|
||||
import { compose } from 'redux';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
|
|
@ -13,26 +11,6 @@ import { ResponsiveImage } from '../../components';
|
|||
|
||||
import css from './SearchMapInfoCard.css';
|
||||
|
||||
// FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
|
||||
// Override draw function to catch errors with map panes being undefined to prevent console errors
|
||||
// https://github.com/tomchentw/react-google-maps/issues/482
|
||||
class CustomOverlayView extends OverlayView {
|
||||
draw() {
|
||||
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
|
||||
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
|
||||
// Add conditional to ensure panes and container exist before drawing
|
||||
if (mapPanes && this.containerElement) {
|
||||
super.draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center label so that caret is pointing to correct pixel.
|
||||
// (vertical positioning: height + arrow) */
|
||||
const getPixelPositionOffset = (width, height) => {
|
||||
return { x: -1 * (width / 2), y: -1 * (height + 3) };
|
||||
};
|
||||
|
||||
// ListingCard is the listing info without overlayview or carousel controls
|
||||
const ListingCard = props => {
|
||||
const { className, clickHandler, intl, isInCarousel, listing, urlToListing } = props;
|
||||
|
|
@ -116,10 +94,6 @@ class SearchMapInfoCard extends Component {
|
|||
onListingInfoCardClicked,
|
||||
} = this.props;
|
||||
const currentListing = ensureListing(listings[this.state.currentListingIndex]);
|
||||
const geolocation = currentListing.attributes.geolocation;
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
|
||||
const hasCarousel = listings.length > 1;
|
||||
const pagination = hasCarousel ? (
|
||||
<div className={classNames(css.paginationInfo, css.borderRadiusInheritBottom)}>
|
||||
|
|
@ -155,25 +129,18 @@ class SearchMapInfoCard extends Component {
|
|||
const caretClass = classNames(css.caret, { [css.caretWithCarousel]: hasCarousel });
|
||||
|
||||
return (
|
||||
<CustomOverlayView
|
||||
position={latLngLiteral}
|
||||
mapPaneName={OverlayView.FLOAT_PANE}
|
||||
getPixelPositionOffset={getPixelPositionOffset}
|
||||
styles={{ zIndex: 1 }}
|
||||
>
|
||||
<div className={classes}>
|
||||
<div className={css.caretShadow} />
|
||||
<ListingCard
|
||||
clickHandler={onListingInfoCardClicked}
|
||||
urlToListing={createURLToListing(currentListing)}
|
||||
listing={currentListing}
|
||||
intl={intl}
|
||||
isInCarousel={hasCarousel}
|
||||
/>
|
||||
{pagination}
|
||||
<div className={caretClass} />
|
||||
</div>
|
||||
</CustomOverlayView>
|
||||
<div className={classes}>
|
||||
<div className={css.caretShadow} />
|
||||
<ListingCard
|
||||
clickHandler={onListingInfoCardClicked}
|
||||
urlToListing={createURLToListing(currentListing)}
|
||||
listing={currentListing}
|
||||
intl={intl}
|
||||
isInCarousel={hasCarousel}
|
||||
/>
|
||||
{pagination}
|
||||
<div className={caretClass} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { OverlayView } from 'react-google-maps';
|
||||
import { OVERLAY_VIEW } from 'react-google-maps/lib/constants';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { propTypes } from '../../util/types';
|
||||
|
|
@ -11,26 +9,6 @@ import config from '../../config';
|
|||
|
||||
import css from './SearchMapPriceLabel.css';
|
||||
|
||||
// FIX "TypeError: Cannot read property 'overlayMouseTarget' of null"
|
||||
// Override draw function to catch errors with map panes being undefined to prevent console errors
|
||||
// https://github.com/tomchentw/react-google-maps/issues/482
|
||||
class CustomOverlayView extends OverlayView {
|
||||
draw() {
|
||||
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
|
||||
const mapPanes = this.state[OVERLAY_VIEW].getPanes();
|
||||
// Add conditional to ensure panes and container exist before drawing
|
||||
if (mapPanes && this.containerElement) {
|
||||
super.draw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Center label so that caret is pointing to correct pixel.
|
||||
// (vertical positioning: height + arrow) */
|
||||
const getPixelPositionOffset = (width, height) => {
|
||||
return { x: -1 * (width / 2), y: -1 * (height + 3) };
|
||||
};
|
||||
|
||||
class SearchMapPriceLabel extends Component {
|
||||
shouldComponentUpdate(nextProps) {
|
||||
const currentListing = ensureListing(this.props.listing);
|
||||
|
|
@ -38,37 +16,31 @@ class SearchMapPriceLabel extends Component {
|
|||
const isSameListing = currentListing.id.uuid === nextListing.id.uuid;
|
||||
const hasSamePrice = currentListing.attributes.price === nextListing.attributes.price;
|
||||
const hasSameActiveStatus = this.props.isActive === nextProps.isActive;
|
||||
const hasSameRefreshToken =
|
||||
this.props.mapComponentRefreshToken === nextProps.mapComponentRefreshToken;
|
||||
|
||||
return !(isSameListing && hasSamePrice && hasSameActiveStatus);
|
||||
return !(isSameListing && hasSamePrice && hasSameActiveStatus && hasSameRefreshToken);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { className, rootClassName, intl, listing, onListingClicked, isActive } = this.props;
|
||||
const currentListing = ensureListing(listing);
|
||||
const { geolocation, price } = currentListing.attributes;
|
||||
const { price } = currentListing.attributes;
|
||||
|
||||
// Create formatted price if currency is known or alternatively show just the unknown currency.
|
||||
const formattedPrice =
|
||||
price && price.currency === config.currency ? formatMoney(intl, price) : price.currency;
|
||||
|
||||
// Explicit type change to object literal for Google OverlayViews (geolocation is SDK type)
|
||||
const latLngLiteral = { lat: geolocation.lat, lng: geolocation.lng };
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const priceLabelClasses = classNames(css.priceLabel, { [css.priceLabelActive]: isActive });
|
||||
const caretClasses = classNames(css.caret, { [css.caretActive]: isActive });
|
||||
|
||||
return (
|
||||
<CustomOverlayView
|
||||
position={latLngLiteral}
|
||||
mapPaneName={OverlayView.OVERLAY_MOUSE_TARGET}
|
||||
getPixelPositionOffset={getPixelPositionOffset}
|
||||
>
|
||||
<button className={classes} onClick={() => onListingClicked(currentListing)}>
|
||||
<div className={css.caretShadow} />
|
||||
<div className={priceLabelClasses}>{formattedPrice}</div>
|
||||
<div className={caretClasses} />
|
||||
</button>
|
||||
</CustomOverlayView>
|
||||
<button className={classes} onClick={() => onListingClicked(currentListing)}>
|
||||
<div className={css.caretShadow} />
|
||||
<div className={priceLabelClasses}>{formattedPrice}</div>
|
||||
<div className={caretClasses} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,17 +5,10 @@ import { connect } from 'react-redux';
|
|||
import { compose } from 'redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import debounce from 'lodash/debounce';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import unionWith from 'lodash/unionWith';
|
||||
import classNames from 'classnames';
|
||||
import config from '../../config';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
import {
|
||||
googleLatLngToSDKLatLng,
|
||||
googleBoundsToSDKBounds,
|
||||
sdkBoundsToFixedCoordinates,
|
||||
hasSameSDKBounds,
|
||||
} from '../../util/googleMaps';
|
||||
import { createResourceLocatorString, pathByRouteName } from '../../util/routes';
|
||||
import { parse, stringify } from '../../util/urlHelpers';
|
||||
import { propTypes } from '../../util/types';
|
||||
|
|
@ -40,7 +33,6 @@ import css from './SearchPage.css';
|
|||
const RESULT_PAGE_SIZE = 24;
|
||||
const MODAL_BREAKPOINT = 768; // Search is in modal on mobile layout
|
||||
const SEARCH_WITH_MAP_DEBOUNCE = 300; // Little bit of debounce before search is initiated.
|
||||
const BOUNDS_FIXED_PRECISION = 8;
|
||||
|
||||
export class SearchPageComponent extends Component {
|
||||
constructor(props) {
|
||||
|
|
@ -51,17 +43,10 @@ export class SearchPageComponent extends Component {
|
|||
isMobileModalOpen: false,
|
||||
};
|
||||
|
||||
// Initiating map creates 'bounds_changes' event
|
||||
// we listen to that event to make new searches
|
||||
// So, if the search comes from location search input (this.viewportBounds == null),
|
||||
// we need to by pass extra searches created by Google Map's 'indle' event.
|
||||
// This is done by keeping track of map's viewport bounds (which differ from location bounds)
|
||||
this.viewportBounds = null;
|
||||
this.modalOpenedBoundsChange = false;
|
||||
this.searchMapListingsInProgress = false;
|
||||
|
||||
this.filters = this.filters.bind(this);
|
||||
this.onIdle = debounce(this.onIdle.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
|
||||
this.onMapMoveEnd = debounce(this.onMapMoveEnd.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
|
||||
this.onOpenMobileModal = this.onOpenMobileModal.bind(this);
|
||||
this.onCloseMobileModal = this.onCloseMobileModal.bind(this);
|
||||
}
|
||||
|
|
@ -81,56 +66,35 @@ export class SearchPageComponent extends Component {
|
|||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!isEqual(this.props.location, nextProps.location)) {
|
||||
// If no mapSearch url parameter is given, this is original location search
|
||||
const { mapSearch } = parse(nextProps.location.search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
if (!mapSearch) {
|
||||
this.viewportBounds = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// We are using Google Maps idle event instead of bounds_changed, since it will not be fired
|
||||
// too often (in the middle of map's pan or zoom activity)
|
||||
onIdle(googleMap) {
|
||||
const { history, location } = this.props;
|
||||
|
||||
// parse query parameters, including a custom attribute named category
|
||||
const { address, bounds, mapSearch, ...rest } = parse(location.search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
|
||||
const viewportGMapBounds = googleMap.getBounds();
|
||||
const viewportBounds = sdkBoundsToFixedCoordinates(
|
||||
googleBoundsToSDKBounds(viewportGMapBounds),
|
||||
BOUNDS_FIXED_PRECISION
|
||||
);
|
||||
|
||||
// ViewportBounds from (previous) rendering differ from viewportBounds currently set to map
|
||||
// I.e. user has changed the map somehow: moved, panned, zoomed, resized
|
||||
const viewportBoundsChanged =
|
||||
this.viewportBounds && !hasSameSDKBounds(this.viewportBounds, viewportBounds);
|
||||
// Callback to determine if new search is needed
|
||||
// when map is moved by user or viewport has changed
|
||||
onMapMoveEnd(viewportBoundsChanged, data) {
|
||||
const { viewportBounds, viewportCenter } = data;
|
||||
|
||||
const routes = routeConfiguration();
|
||||
const searchPagePath = pathByRouteName('SearchPage', routes);
|
||||
const currentPath =
|
||||
typeof window !== 'undefined' && window.location && window.location.pathname;
|
||||
|
||||
// When using the ReusableMapContainer onIdle can fire from other pages than SearchPage too
|
||||
// When using the ReusableMapContainer onMapMoveEnd can fire from other pages than SearchPage too
|
||||
const isSearchPage = currentPath === searchPagePath;
|
||||
|
||||
// If mapSearch url param is given (and we have not just opened mobile map modal)
|
||||
// If mapSearch url param is given
|
||||
// or original location search is rendered once,
|
||||
// we start to react to 'bounds_changed' event by generating new searches
|
||||
if (viewportBoundsChanged && !this.modalOpenedBoundsChange && isSearchPage) {
|
||||
const originMaybe = config.sortSearchByDistance
|
||||
? { origin: googleLatLngToSDKLatLng(viewportGMapBounds.getCenter()) }
|
||||
: {};
|
||||
// we start to react to "mapmoveend" events by generating new searches
|
||||
// (i.e. 'moveend' event in Mapbox and 'bounds_changed' in Google Maps)
|
||||
if (viewportBoundsChanged && isSearchPage) {
|
||||
const { history, location } = this.props;
|
||||
|
||||
// parse query parameters, including a custom attribute named category
|
||||
const { address, bounds, mapSearch, ...rest } = parse(location.search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
|
||||
//const viewportMapCenter = SearchMap.getMapCenter(map);
|
||||
const originMaybe = config.sortSearchByDistance ? { origin: viewportCenter } : {};
|
||||
|
||||
const searchParams = {
|
||||
address,
|
||||
...originMaybe,
|
||||
|
|
@ -139,11 +103,7 @@ export class SearchPageComponent extends Component {
|
|||
...validFilterParams(rest, this.filters()),
|
||||
};
|
||||
|
||||
this.viewportBounds = viewportBounds;
|
||||
history.push(createResourceLocatorString('SearchPage', routes, {}, searchParams));
|
||||
} else {
|
||||
this.viewportBounds = viewportBounds;
|
||||
this.modalOpenedBoundsChange = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -200,7 +160,6 @@ export class SearchPageComponent extends Component {
|
|||
|
||||
const onMapIconClick = () => {
|
||||
this.useLocationSearchBounds = true;
|
||||
this.modalOpenedBoundsChange = true;
|
||||
this.setState({ isSearchMapOpenOnMobile: true });
|
||||
};
|
||||
|
||||
|
|
@ -263,12 +222,13 @@ export class SearchPageComponent extends Component {
|
|||
activeListingId={activeListingId}
|
||||
bounds={bounds}
|
||||
center={origin}
|
||||
isSearchMapOpenOnMobile={this.state.isSearchMapOpenOnMobile}
|
||||
location={location}
|
||||
listings={mapListings || []}
|
||||
onIdle={this.onIdle}
|
||||
onMapMoveEnd={this.onMapMoveEnd}
|
||||
onCloseAsModal={() => {
|
||||
onManageDisableScrolling('SearchPage.map', false);
|
||||
}}
|
||||
useLocationSearchBounds={!this.viewportBounds}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -88,10 +88,15 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
<div>
|
||||
<withRouter(SearchMapComponent)
|
||||
activeListingId={null}
|
||||
isSearchMapOpenOnMobile={false}
|
||||
listings={Array []}
|
||||
location={
|
||||
Object {
|
||||
"search": "",
|
||||
}
|
||||
}
|
||||
onCloseAsModal={[Function]}
|
||||
onIdle={[Function]}
|
||||
useLocationSearchBounds={true}
|
||||
onMapMoveEnd={[Function]}
|
||||
/>
|
||||
</div>
|
||||
</withViewport(ModalInMobileComponent)>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// NOTE: renderdeep doesn't work due to Google Maps API integration
|
||||
// NOTE: renderdeep doesn't work due to map integration
|
||||
import React from 'react';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { fakeIntl } from '../../util/test-data';
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
// NOTE: renderdeep doesn't work due to Google Maps API integration
|
||||
// NOTE: renderdeep doesn't work due to map integration
|
||||
import React from 'react';
|
||||
import { renderShallow } from '../../util/test-helpers';
|
||||
import { fakeIntl } from '../../util/test-data';
|
||||
|
|
|
|||
|
|
@ -118,7 +118,8 @@
|
|||
|
||||
/* ================ LocationAutocompleteInput bottom attribution padding ================ */
|
||||
|
||||
--locationAutocompleteBottomPadding: 72px;
|
||||
/* Google Maps needs 72px bottom padding to accommodate logo, Mapbox doesn't have one */
|
||||
--locationAutocompleteBottomPadding: 8px;
|
||||
|
||||
/* ================ Clearfix solution ================ */
|
||||
|
||||
|
|
|
|||
|
|
@ -90,33 +90,7 @@ export const getPlacePredictions = (search, sessionToken) =>
|
|||
});
|
||||
|
||||
/**
|
||||
* Convert Google formatted LatLng object to Sharetribe SDK's LatLng coordinate format
|
||||
*
|
||||
* @param {LatLng} googleLatLng - Google Maps LatLng
|
||||
*
|
||||
* @return {SDKLatLng} - Converted latLng coordinate
|
||||
*/
|
||||
export const googleLatLngToSDKLatLng = googleLatLng => {
|
||||
return new SDKLatLng(googleLatLng.lat(), googleLatLng.lng());
|
||||
};
|
||||
|
||||
/**
|
||||
* Convert Google formatted bounds object to Sharetribe SDK's bounds format
|
||||
*
|
||||
* @param {LatLngBounds} googleBounds - Google Maps LatLngBounds
|
||||
*
|
||||
* @return {SDKLatLngBounds} - Converted bounds
|
||||
*/
|
||||
export const googleBoundsToSDKBounds = googleBounds => {
|
||||
if (!googleBounds) {
|
||||
return null;
|
||||
}
|
||||
const ne = googleBounds.getNorthEast();
|
||||
const sw = googleBounds.getSouthWest();
|
||||
return new SDKLatLngBounds(new SDKLatLng(ne.lat(), ne.lng()), new SDKLatLng(sw.lat(), sw.lng()));
|
||||
};
|
||||
|
||||
/**
|
||||
* Deprecation: use function from src/util/maps.js
|
||||
* Cut some precision from bounds coordinates to tackle subtle map movements
|
||||
* when map is moved manually
|
||||
*
|
||||
|
|
@ -134,6 +108,7 @@ export const sdkBoundsToFixedCoordinates = (sdkBounds, fixedPrecision) => {
|
|||
};
|
||||
|
||||
/**
|
||||
* Deprecation: use function from src/util/maps.js
|
||||
* Check if given bounds object have the same coordinates
|
||||
*
|
||||
* @param {LatLngBounds} sdkBounds1 - bounds #1 to be compared
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import seedrandom from 'seedrandom';
|
|||
import { types as sdkTypes } from './sdkLoader';
|
||||
import config from '../config';
|
||||
|
||||
const { LatLng } = sdkTypes;
|
||||
const { LatLng, LatLngBounds } = sdkTypes;
|
||||
|
||||
const EARTH_RADIUS = 6371000; /* meters */
|
||||
const DEG_TO_RAD = Math.PI / 180.0;
|
||||
|
|
@ -155,3 +155,40 @@ export const circlePolyline = (latlng, radius) => {
|
|||
|
||||
return points;
|
||||
};
|
||||
|
||||
/**
|
||||
* Cut some precision from bounds coordinates to tackle subtle map movements
|
||||
* when map is moved manually
|
||||
*
|
||||
* @param {LatLngBounds} sdkBounds - bounds to be changed to fixed precision
|
||||
* @param {Number} fixedPrecision - integer to be used on tofixed() change.
|
||||
*
|
||||
* @return {LatLngBounds} - bounds cut to given fixed precision
|
||||
*/
|
||||
export const sdkBoundsToFixedCoordinates = (sdkBounds, fixedPrecision) => {
|
||||
const fixed = n => Number.parseFloat(n.toFixed(fixedPrecision));
|
||||
const ne = new LatLng(fixed(sdkBounds.ne.lat), fixed(sdkBounds.ne.lng));
|
||||
const sw = new LatLng(fixed(sdkBounds.sw.lat), fixed(sdkBounds.sw.lng));
|
||||
|
||||
return new LatLngBounds(ne, sw);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if given bounds object have the same coordinates
|
||||
*
|
||||
* @param {LatLngBounds} sdkBounds1 - bounds #1 to be compared
|
||||
* @param {LatLngBounds} sdkBounds2 - bounds #2 to be compared
|
||||
*
|
||||
* @return {boolean} - true if bounds are the same
|
||||
*/
|
||||
export const hasSameSDKBounds = (sdkBounds1, sdkBounds2) => {
|
||||
if (!(sdkBounds1 instanceof LatLngBounds) || !(sdkBounds2 instanceof LatLngBounds)) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
sdkBounds1.ne.lat === sdkBounds2.ne.lat &&
|
||||
sdkBounds1.ne.lng === sdkBounds2.ne.lng &&
|
||||
sdkBounds1.sw.lat === sdkBounds2.sw.lat &&
|
||||
sdkBounds1.sw.lng === sdkBounds2.sw.lng
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5032,6 +5032,10 @@ map-visit@^1.0.0:
|
|||
dependencies:
|
||||
object-visit "^1.0.0"
|
||||
|
||||
mapbox-gl-multitouch@^1.0.3:
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/mapbox-gl-multitouch/-/mapbox-gl-multitouch-1.0.3.tgz#db8bbe86a15d8398e3315d97305c9edde3f0f0d7"
|
||||
|
||||
marker-clusterer-plus@^2.1.4:
|
||||
version "2.1.4"
|
||||
resolved "https://registry.yarnpkg.com/marker-clusterer-plus/-/marker-clusterer-plus-2.1.4.tgz#f8eff74d599dab3b7d0e3fed5264ea0e704f5d67"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue