import React, { Component } from 'react'; import { array, bool, func, number, oneOf, object, shape, string } from 'prop-types'; import { injectIntl, intlShape } from 'react-intl'; import { connect } from 'react-redux'; import { compose } from 'redux'; import { withRouter } from 'react-router-dom'; import debounce from 'lodash/debounce'; import unionWith from 'lodash/unionWith'; import classNames from 'classnames'; import config from '../../config'; import routeConfiguration from '../../routeConfiguration'; import { createResourceLocatorString, pathByRouteName } from '../../util/routes'; import { parse, stringify } from '../../util/urlHelpers'; import { propTypes } from '../../util/types'; import { getListingsById } from '../../ducks/marketplaceData.duck'; import { manageDisableScrolling, isScrollingDisabled } from '../../ducks/UI.duck'; import { SearchMap, ModalInMobile, Page } from '../../components'; import { TopbarContainer } from '../../containers'; import { searchListings, searchMapListings, setActiveListing } from './SearchPage.duck'; import { pickSearchParamsOnly, validURLParamsForExtendedData, validFilterParams, createSearchResultSchema, } from './SearchPage.helpers'; import MainPanel from './MainPanel'; 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 = 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. export class SearchPageComponent extends Component { constructor(props) { super(props); this.state = { isSearchMapOpenOnMobile: props.tab === 'map', isMobileModalOpen: false, }; this.searchMapListingsInProgress = false; this.filters = this.filters.bind(this); this.onMapMoveEnd = debounce(this.onMapMoveEnd.bind(this), SEARCH_WITH_MAP_DEBOUNCE); this.onOpenMobileModal = this.onOpenMobileModal.bind(this); this.onCloseMobileModal = this.onCloseMobileModal.bind(this); } filters() { const { categories, amenities, priceFilterConfig } = this.props; return { categoryFilter: { paramName: 'pub_category', options: categories, }, amenitiesFilter: { paramName: 'pub_amenities', options: amenities, }, priceFilter: { paramName: 'price', config: priceFilterConfig, }, }; } // 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 onMapMoveEnd can fire from other pages than SearchPage too const isSearchPage = currentPath === searchPagePath; // If mapSearch url param is given // or original location search is rendered once, // 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, bounds: viewportBounds, mapSearch: true, ...validFilterParams(rest, this.filters()), }; history.push(createResourceLocatorString('SearchPage', routes, {}, searchParams)); } } // Invoked when a modal is opened from a child component, // for example when a filter modal is opened in mobile view onOpenMobileModal() { this.setState({ isMobileModalOpen: true }); } // Invoked when a modal is closed from a child component, // for example when a filter modal is opened in mobile view onCloseMobileModal() { this.setState({ isMobileModalOpen: false }); } render() { const { intl, listings, location, mapListings, onManageDisableScrolling, pagination, scrollingDisabled, searchInProgress, searchListingsError, searchParams, activeListingId, onActivateListing, } = this.props; // eslint-disable-next-line no-unused-vars const { mapSearch, page, ...searchInURL } = parse(location.search, { latlng: ['origin'], latlngBounds: ['bounds'], }); const filters = this.filters(); // urlQueryParams doesn't contain page specific url params // like mapSearch, page or origin (origin depends on config.sortSearchByDistance) const urlQueryParams = pickSearchParamsOnly(searchInURL, filters); // Page transition might initially use values from previous search const urlQueryString = stringify(urlQueryParams); const paramsQueryString = stringify(pickSearchParamsOnly(searchParams, filters)); const searchParamsAreInSync = urlQueryString === paramsQueryString; const validQueryParams = validURLParamsForExtendedData(searchInURL, filters); const isWindowDefined = typeof window !== 'undefined'; const isMobileLayout = isWindowDefined && window.innerWidth < MODAL_BREAKPOINT; const shouldShowSearchMap = !isMobileLayout || (isMobileLayout && this.state.isSearchMapOpenOnMobile); const onMapIconClick = () => { this.useLocationSearchBounds = true; this.setState({ isSearchMapOpenOnMobile: true }); }; const { address, bounds, origin } = searchInURL || {}; const { title, description, schema } = createSearchResultSchema(listings, address, intl); // Set topbar class based on if a modal is open in // a child component const topbarClasses = this.state.isMobileModalOpen ? classNames(css.topbarBehindModal, css.topbar) : css.topbar; // N.B. openMobileMap button is sticky. // For some reason, stickyness doesn't work on Safari, if the element is