flex-template-web/src/containers/SearchPage/SearchPage.js

403 lines
13 KiB
JavaScript

import React, { Component, PropTypes } from 'react';
import { FormattedMessage } from 'react-intl';
import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { debounce, isEqual, unionWith } from 'lodash';
import classNames from 'classnames';
import config from '../../config';
import { withFlattenedRoutes } from '../../util/contextHelpers';
import { googleLatLngToSDKLatLng, googleBoundsToSDKBounds } from '../../util/googleMaps';
import { createResourceLocatorString } from '../../util/routes';
import { parse, stringify } from '../../util/urlHelpers';
import * as propTypes from '../../util/propTypes';
import { getListingsById } from '../../ducks/marketplaceData.duck';
import { logout, authenticationInProgress } from '../../ducks/Auth.duck';
import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck';
import { SearchMap, ModalInMobile, PageLayout, SearchResultsPanel, Topbar } from '../../components';
import { searchListings, searchMapListings } from './SearchPage.duck';
import MapIcon from './MapIcon';
import css from './SearchPage.css';
// Pagination page size might need to be dynamic on responsive page layouts
// Current design has max 3 columns 12 is divisible by 2 and 3
// So, there's enough cards to fill all columns on full pagination pages
const RESULT_PAGE_SIZE = 12;
const SHARETRIBE_API_MAX_PAGE_SIZE = 100;
const MAX_SEARCH_RESULT_PAGES_ON_MAP = 5; // 100 * 5 = 500 listings are shown on a map.
const DEBOUNCE_MAP_BOUNDS_CHANGE = 500; // bounds_change event is fired too often while dragging
const MODAL_BREAKPOINT = 768; /* Search is in modal on mobile layout */
const pickSearchParamsOnly = params => {
const { address, origin, bounds } = params || {};
return { address, origin, bounds };
};
export class SearchPageComponent extends Component {
constructor(props) {
super(props);
this.state = {
isSearchMapOpenOnMobile: 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,
// we need to by pass 2nd search created by initial 'bounds_changes' event
this.useLocationSearchBounds = true;
this.modalOpenedBoundsChange = false;
this.onBoundsChanged = debounce(this.onBoundsChanged.bind(this), DEBOUNCE_MAP_BOUNDS_CHANGE);
this.fetchMoreListingsToMap = this.fetchMoreListingsToMap.bind(this);
}
componentDidMount() {
this.fetchMoreListingsToMap(this.props.location);
}
componentWillReceiveProps(nextProps) {
if (!isEqual(this.props.location, nextProps.location)) {
this.fetchMoreListingsToMap(nextProps.location);
// If no boundsChanged url parameter is given, this is original location search
const { boundsChanged } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
if (!boundsChanged) {
this.useLocationSearchBounds = true;
}
}
}
onBoundsChanged(googleMap) {
const { flattenedRoutes, history, location } = this.props;
const { address, country, boundsChanged } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
// If boundsChanged url param is given (and we have not just opened mobile map modal)
// or original location search is rendered once,
// we start to react to 'bounds_changed' event by generating new searches
if ((boundsChanged && !this.modalOpenedBoundsChange) || !this.useLocationSearchBounds) {
const viewportBounds = googleMap.getBounds();
const bounds = googleBoundsToSDKBounds(viewportBounds);
const origin = googleLatLngToSDKLatLng(viewportBounds.getCenter());
const searchParams = { address, origin, bounds, country, boundsChanged: true };
history.push(createResourceLocatorString('SearchPage', flattenedRoutes, {}, searchParams));
} else {
this.useLocationSearchBounds = false;
this.modalOpenedBoundsChange = false;
}
}
fetchMoreListingsToMap(location) {
const { onSearchMapListings } = this.props;
const searchInURL = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const perPage = SHARETRIBE_API_MAX_PAGE_SIZE;
const page = 1;
const searchParamsForMapResults = { ...searchInURL, page, perPage };
// Search more listings for map
onSearchMapListings(searchParamsForMapResults)
.then(response => {
const hasNextPage = page < response.data.meta.totalPages &&
page < MAX_SEARCH_RESULT_PAGES_ON_MAP;
if (hasNextPage) {
onSearchMapListings({ ...searchParamsForMapResults, page: page + 1 });
}
})
.catch(error => {
// In case of error, stop recursive loop and report error.
// eslint-disable-next-line no-console
console.error(`An error (${error} occured while trying to retrieve map listings`);
});
}
render() {
const {
authInfoError,
authInProgress,
currentUser,
currentUserHasListings,
history,
isAuthenticated,
listings,
location,
logoutError,
mapListings,
notificationCount,
onLogout,
onManageDisableScrolling,
pagination,
scrollingDisabled,
searchInProgress,
searchListingsError,
searchParams,
tab,
} = this.props;
// eslint-disable-next-line no-unused-vars
const { boundsChanged, page, ...searchInURL } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
// Page transition might initially use values from previous search
const searchParamsInURL = stringify(pickSearchParamsOnly(searchInURL));
const searchParamsInProps = stringify(pickSearchParamsOnly(searchParams));
const searchParamsMatch = searchParamsInURL === searchParamsInProps;
const { address, bounds, origin } = searchInURL || {};
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
const totalItems = searchParamsMatch && hasPaginationInfo ? pagination.totalItems : 0;
const listingsAreLoaded = !searchInProgress && searchParamsMatch && hasPaginationInfo;
const searchError = (
<h2 className={css.error}>
<FormattedMessage id="SearchPage.searchError" />
</h2>
);
const resultsFoundNoAddress = (
<h2>
<FormattedMessage id="SearchPage.foundResults" values={{ count: totalItems }} />
</h2>
);
const addressNode = address
? <span className={css.searchString}>{searchInURL.address.split(', ')[0]}</span>
: null;
const resultsFoundWithAddress = (
<h2>
<FormattedMessage
id="SearchPage.foundResultsWithAddress"
values={{ count: totalItems, address: addressNode }}
/>
</h2>
);
const resultsFound = address && !boundsChanged
? resultsFoundWithAddress
: resultsFoundNoAddress;
const noResults = (
<h2>
<FormattedMessage id="SearchPage.noResults" />
</h2>
);
const loadingResults = (
<h2>
<FormattedMessage id="SearchPage.loadingResults" />
</h2>
);
const searchMap = (
<SearchMap
bounds={bounds}
center={origin}
listings={mapListings || []}
onBoundsChanged={this.onBoundsChanged}
isOpenOnModal={this.state.isSearchMapOpenOnMobile}
onCloseAsModal={() => {
onManageDisableScrolling('SearchPage.map', false);
}}
/>
);
const showSearchMapInMobile = this.state.isSearchMapOpenOnMobile ? searchMap : null;
const isWindowDefined = typeof window !== 'undefined';
const searchMapMaybe = isWindowDefined && window.innerWidth < MODAL_BREAKPOINT
? showSearchMapInMobile
: searchMap;
const searchParamsForPagination = parse(location.search);
return (
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title={`Search page: ${tab}`}
scrollingDisabled={scrollingDisabled}
>
<Topbar
className={css.topbar}
isAuthenticated={isAuthenticated}
authInProgress={authInProgress}
currentUser={currentUser}
currentUserHasListings={currentUserHasListings}
notificationCount={notificationCount}
history={history}
location={location}
onLogout={onLogout}
onManageDisableScrolling={onManageDisableScrolling}
/>
<div className={css.container}>
<div className={css.searchResultContainer}>
<div className={css.searchResultSummary}>
{searchListingsError ? searchError : null}
{listingsAreLoaded && totalItems > 0 ? resultsFound : null}
{listingsAreLoaded && totalItems === 0 ? noResults : null}
{searchInProgress ? loadingResults : null}
</div>
<div className={classNames(css.listings, { [css.newSearchInProgress]: !listingsAreLoaded })}>
<SearchResultsPanel
className={css.searchListingsPanel}
currencyConfig={config.currencyConfig}
listings={listings}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
>
<button
className={classNames(css.openMobileMap, { [css.openMobileMapSticky] : listings.length > 0 })}
onClick={() => {
this.useLocationSearchBounds = true;
this.modalOpenedBoundsChange = true;
this.setState({ isSearchMapOpenOnMobile: true });
}}
>
<MapIcon className={css.openMobileMapIcon} />
<FormattedMessage id="SearchPage.openMapView" />
</button>
</SearchResultsPanel>
</div>
</div>
<ModalInMobile
className={css.mapPanel}
id="SearchPage.map"
isModalOpenOnMobile={this.state.isSearchMapOpenOnMobile}
onClose={() => this.setState({ isSearchMapOpenOnMobile: false })}
showAsModalMaxWidth={MODAL_BREAKPOINT}
onManageDisableScrolling={onManageDisableScrolling}
>
<div className={css.map}>
{searchMapMaybe}
</div>
</ModalInMobile>
</div>
</PageLayout>
);
}
}
SearchPageComponent.defaultProps = {
authInfoError: null,
currentUser: null,
listings: [],
logoutError: null,
mapListings: [],
notificationCount: 0,
pagination: null,
searchListingsError: null,
searchParams: {},
tab: 'listings',
};
const { array, arrayOf, bool, func, instanceOf, number, oneOf, object, shape, string } = PropTypes;
SearchPageComponent.propTypes = {
authInfoError: instanceOf(Error),
authInProgress: bool.isRequired,
currentUser: propTypes.currentUser,
currentUserHasListings: bool.isRequired,
isAuthenticated: bool.isRequired,
listings: array,
mapListings: array,
logoutError: instanceOf(Error),
notificationCount: number,
onLogout: func.isRequired,
onManageDisableScrolling: func.isRequired,
onSearchMapListings: func.isRequired,
pagination: propTypes.pagination,
scrollingDisabled: bool.isRequired,
searchInProgress: bool.isRequired,
searchListingsError: instanceOf(Error),
searchParams: object,
tab: oneOf(['filters', 'listings', 'map']).isRequired,
// from withFlattenedRoutes
flattenedRoutes: arrayOf(propTypes.route).isRequired,
// from withRouter
history: shape({
push: func.isRequired,
}).isRequired,
location: shape({
search: string.isRequired,
}).isRequired,
};
const mapStateToProps = state => {
const {
currentPageResultIds,
pagination,
searchInProgress,
searchListingsError,
searchParams,
searchMapListingIds,
} = state.SearchPage;
const { authInfoError, isAuthenticated, logoutError } = state.Auth;
const {
currentUser,
currentUserHasListings,
currentUserNotificationCount: notificationCount,
} = state.user;
const pageListings = getListingsById(state, currentPageResultIds);
const mapListings = getListingsById(
state,
unionWith(currentPageResultIds, searchMapListingIds, (id1, id2) => id1.uuid === id2.uuid)
);
return {
authInfoError,
logoutError,
listings: pageListings,
mapListings,
pagination,
searchInProgress,
searchListingsError,
searchParams,
scrollingDisabled: isScrollingDisabled(state),
isAuthenticated,
authInProgress: authenticationInProgress(state),
currentUser,
currentUserHasListings,
notificationCount,
};
};
const mapDispatchToProps = dispatch => ({
onLogout: historyPush => dispatch(logout(historyPush)),
onManageDisableScrolling: (componentId, disableScrolling) =>
dispatch(manageDisableScrolling(componentId, disableScrolling)),
onSearchMapListings: searchParams => dispatch(searchMapListings(searchParams)),
});
const SearchPage = compose(
connect(mapStateToProps, mapDispatchToProps),
withFlattenedRoutes,
withRouter
)(SearchPageComponent);
SearchPage.loadData = (params, search) => {
const queryParams = parse(search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const page = queryParams.page || 1;
return searchListings({
...queryParams,
page,
perPage: RESULT_PAGE_SIZE,
include: ['author', 'images'],
});
};
export default SearchPage;