mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-29 13:06:03 +10:00
403 lines
13 KiB
JavaScript
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;
|