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 = (

); const resultsFoundNoAddress = (

); const addressNode = address ? {searchInURL.address.split(', ')[0]} : null; const resultsFoundWithAddress = (

); const resultsFound = address && !boundsChanged ? resultsFoundWithAddress : resultsFoundNoAddress; const noResults = (

); const loadingResults = (

); const searchMap = ( { 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 (
{searchListingsError ? searchError : null} {listingsAreLoaded && totalItems > 0 ? resultsFound : null} {listingsAreLoaded && totalItems === 0 ? noResults : null} {searchInProgress ? loadingResults : null}
this.setState({ isSearchMapOpenOnMobile: false })} showAsModalMaxWidth={MODAL_BREAKPOINT} onManageDisableScrolling={onManageDisableScrolling} >
{searchMapMaybe}
); } } 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;