mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-28 12:43:11 +10:00
Merge pull request #795 from sharetribe/refactor-searchpage
Refactor searchpage
This commit is contained in:
commit
ea6ab42429
7 changed files with 448 additions and 328 deletions
|
|
@ -41,10 +41,6 @@ import { createResourceLocatorString } from '../../util/routes';
|
|||
import { InlineTextButton } from '../../components';
|
||||
import css from './SearchFiltersPanel.css';
|
||||
|
||||
// Create constants from url params and uset them in FILTERS array and while adding actual filters
|
||||
// e.g. const MULTI_SELECT_URL_PARAM = 'pub_filterX';
|
||||
const FILTERS = [];
|
||||
|
||||
class SearchFiltersPanelComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
@ -82,9 +78,9 @@ class SearchFiltersPanelComponent extends Component {
|
|||
|
||||
// Reset all filter query parameters
|
||||
resetAll(e) {
|
||||
const { urlQueryParams, history, onClosePanel } = this.props;
|
||||
const { urlQueryParams, customURLParamToConfig, history, onClosePanel } = this.props;
|
||||
|
||||
const queryParams = omit(urlQueryParams, FILTERS);
|
||||
const queryParams = omit(urlQueryParams, Object.keys(customURLParamToConfig));
|
||||
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
|
||||
|
||||
// Ensure that panel closes (if now changes have been made)
|
||||
|
|
@ -161,6 +157,7 @@ SearchFiltersPanelComponent.propTypes = {
|
|||
rootClassName: string,
|
||||
className: string,
|
||||
urlQueryParams: object.isRequired,
|
||||
customURLParamToConfig: object.isRequired,
|
||||
onClosePanel: func.isRequired,
|
||||
|
||||
// from injectIntl
|
||||
|
|
|
|||
157
src/containers/SearchPage/MainPanel.js
Normal file
157
src/containers/SearchPage/MainPanel.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import React, { Component } from 'react';
|
||||
import { array, bool, func, object, number, string } from 'prop-types';
|
||||
import { FormattedMessage } from 'react-intl';
|
||||
import classNames from 'classnames';
|
||||
import { propTypes } from '../../util/types';
|
||||
import {
|
||||
SearchResultsPanel,
|
||||
SearchFilters,
|
||||
SearchFiltersMobile,
|
||||
SearchFiltersPanel,
|
||||
} from '../../components';
|
||||
|
||||
import css from './SearchPage.css';
|
||||
|
||||
class MainPanel extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isSearchFiltersPanelOpen: false };
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
rootClassName,
|
||||
urlQueryParams,
|
||||
listings,
|
||||
searchInProgress,
|
||||
searchListingsError,
|
||||
searchParamsAreInSync,
|
||||
onActivateListing,
|
||||
onManageDisableScrolling,
|
||||
onMapIconClick,
|
||||
pagination,
|
||||
searchParamsForPagination,
|
||||
showAsModalMaxWidth,
|
||||
customURLParamToConfig,
|
||||
primaryFilters,
|
||||
secondaryFilters,
|
||||
} = this.props;
|
||||
|
||||
const isSearchFiltersPanelOpen = !!secondaryFilters && this.state.isSearchFiltersPanelOpen;
|
||||
const searchFiltersPanelSelectedCount = !secondaryFilters
|
||||
? 0
|
||||
: Object.keys(customURLParamToConfig)
|
||||
.map(key => urlQueryParams[key])
|
||||
.filter(param => !!param).length;
|
||||
|
||||
const searchFiltersPanelProps = !!secondaryFilters
|
||||
? {
|
||||
isSearchFiltersPanelOpen: this.state.isSearchFiltersPanelOpen,
|
||||
toggleSearchFiltersPanel: isOpen => {
|
||||
this.setState({ isSearchFiltersPanelOpen: isOpen });
|
||||
},
|
||||
searchFiltersPanelSelectedCount,
|
||||
}
|
||||
: {};
|
||||
|
||||
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
|
||||
const totalItems = searchParamsAreInSync && hasPaginationInfo ? pagination.totalItems : 0;
|
||||
const listingsAreLoaded = !searchInProgress && searchParamsAreInSync && hasPaginationInfo;
|
||||
|
||||
const classes = classNames(rootClassName || css.searchResultContainer, className);
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<SearchFilters
|
||||
className={css.searchFilters}
|
||||
urlQueryParams={urlQueryParams}
|
||||
listingsAreLoaded={listingsAreLoaded}
|
||||
resultsCount={totalItems}
|
||||
searchInProgress={searchInProgress}
|
||||
searchListingsError={searchListingsError}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
{...searchFiltersPanelProps}
|
||||
{...primaryFilters}
|
||||
/>
|
||||
<SearchFiltersMobile
|
||||
className={css.searchFiltersMobile}
|
||||
urlQueryParams={urlQueryParams}
|
||||
listingsAreLoaded={listingsAreLoaded}
|
||||
resultsCount={totalItems}
|
||||
searchInProgress={searchInProgress}
|
||||
searchListingsError={searchListingsError}
|
||||
showAsModalMaxWidth={showAsModalMaxWidth}
|
||||
onMapIconClick={onMapIconClick}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onOpenModal={this.onOpenMobileModal}
|
||||
onCloseModal={this.onCloseMobileModal}
|
||||
{...primaryFilters}
|
||||
/>
|
||||
{isSearchFiltersPanelOpen ? (
|
||||
<div className={classNames(css.searchFiltersPanel)}>
|
||||
<SearchFiltersPanel
|
||||
urlQueryParams={urlQueryParams}
|
||||
customURLParamToConfig={customURLParamToConfig}
|
||||
listingsAreLoaded={listingsAreLoaded}
|
||||
onClosePanel={() => this.setState({ isSearchFiltersPanelOpen: false })}
|
||||
{...secondaryFilters}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classNames(css.listings, {
|
||||
[css.newSearchInProgress]: !listingsAreLoaded,
|
||||
})}
|
||||
>
|
||||
{searchListingsError ? (
|
||||
<h2 className={css.error}>
|
||||
<FormattedMessage id="SearchPage.searchError" />
|
||||
</h2>
|
||||
) : null}
|
||||
<SearchResultsPanel
|
||||
className={css.searchListingsPanel}
|
||||
listings={listings}
|
||||
pagination={listingsAreLoaded ? pagination : null}
|
||||
search={searchParamsForPagination}
|
||||
setActiveListing={onActivateListing}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MainPanel.defaultProps = {
|
||||
className: null,
|
||||
rootClassName: null,
|
||||
listings: [],
|
||||
resultsCount: 0,
|
||||
pagination: null,
|
||||
searchParamsForPagination: {},
|
||||
primaryFilters: null,
|
||||
secondaryFilters: null,
|
||||
};
|
||||
|
||||
MainPanel.propTypes = {
|
||||
className: string,
|
||||
rootClassName: string,
|
||||
|
||||
urlQueryParams: object.isRequired,
|
||||
listings: array,
|
||||
searchInProgress: bool.isRequired,
|
||||
searchListingsError: propTypes.error,
|
||||
searchParamsAreInSync: bool.isRequired,
|
||||
onActivateListing: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
onMapIconClick: func.isRequired,
|
||||
pagination: propTypes.pagination,
|
||||
searchParamsForPagination: object,
|
||||
showAsModalMaxWidth: number.isRequired,
|
||||
customURLParamToConfig: object.isRequired,
|
||||
primaryFilters: object,
|
||||
secondaryFilters: object,
|
||||
};
|
||||
|
||||
export default MainPanel;
|
||||
71
src/containers/SearchPage/README.md
Normal file
71
src/containers/SearchPage/README.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# SearchPage
|
||||
|
||||
SearchPage component has roughly 3 sections inside its layout:
|
||||
Topbar, MainPanel (for results and filters), and Map
|
||||
|
||||
So, rough JSX presentation is something like:
|
||||
```jsx
|
||||
<Page>
|
||||
<TopbarContainer />
|
||||
<MainPanel />
|
||||
<SearchMap />
|
||||
<Page>
|
||||
```
|
||||
|
||||
Searches can be made by each of these components.
|
||||
|
||||
* Topbar contains location search (LocationAutocompleteInput)
|
||||
* MainPanel has Filters that can fine-tune current location search
|
||||
* SearchMap can create new location searches when the map's bounding box changes
|
||||
(i.e. moving, zooming, etc.)
|
||||
|
||||
## Topbar
|
||||
|
||||
In contrast to other pages, Topbar gets `currentSearchParams` among other props. This makes it
|
||||
possible for Topbar to take current filters into account.
|
||||
|
||||
## MainPanel
|
||||
|
||||
MainPanel has two functions: showing searchResults and showing filters. There are two sets of
|
||||
filters that can be passed to it: `primaryFilters`, `secondaryFilters`.
|
||||
|
||||
Primary filters are filters that are shown always on top of SearchResults as dropdown-selections.
|
||||
We recommend that only 1 - 3 primary filters are passed in since they start to take too much space
|
||||
on narrow screens.
|
||||
|
||||
Secondary filters create one more button to the space containing primary filters: *More filters*.
|
||||
This more-filters button opens up a SearchFiltersPanel component that can be changed to show those
|
||||
extra filters passed to it.
|
||||
**Note:** Currently, it doesn't contain any filter components by default, even if you pass in some
|
||||
filter data. Creating those filter components is part of the customization process.
|
||||
|
||||
On the mobile layout, all the filters are shown in separate mobile filters panel.
|
||||
|
||||
## SearchMap
|
||||
|
||||
SearchMap listens to 'idle' event and SearchPage function `onIndle` can create a new location search if
|
||||
SearchMap's bounds have changed enough.
|
||||
|
||||
## Other things to consider
|
||||
|
||||
### URL Params vs marketplace-custom-config.js
|
||||
|
||||
Filter handling needs some mapping between configured extended data. For example,
|
||||
*marketplace-custom-config.js* defines all the categories used by the app, but that data needs to be in
|
||||
format `pub_category=selectedCategory` when we talk with the API. (We also use this format in URL.)
|
||||
|
||||
This mapping is done at the beginning of the file:
|
||||
```
|
||||
const CATEGORY_URL_PARAM = 'pub_category';
|
||||
const AMENITIES_URL_PARAM = 'pub_amenities';
|
||||
|
||||
const customURLParamToConfig = {
|
||||
[CATEGORY_URL_PARAM]: 'categories',
|
||||
[AMENITIES_URL_PARAM]: 'amenities',
|
||||
};
|
||||
```
|
||||
|
||||
### SeachPage schema / SEO
|
||||
|
||||
Schema is created inside `createSearchResultSchema` in *SearchPage.helpers.js*. It needs listings
|
||||
and address to make meaningful JSON-LD presentation for search engines.
|
||||
98
src/containers/SearchPage/SearchPage.helpers.js
Normal file
98
src/containers/SearchPage/SearchPage.helpers.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import { intersection } from 'lodash';
|
||||
import config from '../../config';
|
||||
import { createResourceLocatorString } from '../../util/routes';
|
||||
import { createSlug } from '../../util/urlHelpers';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
|
||||
// customURLParams
|
||||
export const validURLParamForExtendedData = (paramKey, urlParams, customConfigKeys) => {
|
||||
const configKey = customConfigKeys[paramKey];
|
||||
const value = urlParams[paramKey];
|
||||
const valueArray = value ? value.split(',') : [];
|
||||
|
||||
if (configKey && valueArray.length > 0) {
|
||||
const allowedValues = config.custom[configKey].map(a => a.key);
|
||||
const validValues = intersection(valueArray, allowedValues).join(',');
|
||||
return validValues.length > 0 ? { [paramKey]: validValues } : {};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// validate filter params
|
||||
export const validURLParamsForExtendedData = (params, customURLParams, customURLParamToConfig) => {
|
||||
const paramKeys = Object.keys(params);
|
||||
return paramKeys.reduce((validParams, paramKey) => {
|
||||
return customURLParams.includes(paramKey)
|
||||
? {
|
||||
...validParams,
|
||||
...validURLParamForExtendedData(paramKey, params, customURLParamToConfig),
|
||||
}
|
||||
: { ...validParams, [paramKey]: params[paramKey] };
|
||||
}, {});
|
||||
};
|
||||
|
||||
// extract search parameters, including a custom URL params
|
||||
// which are validated by mapping the values to marketplace custom config.
|
||||
export const pickSearchParamsOnly = (params, customURLParams, customURLParamToConfig) => {
|
||||
const { address, origin, bounds, country, ...rest } = params || {};
|
||||
const boundsMaybe = bounds ? { bounds } : {};
|
||||
const originMaybe = config.sortSearchByDistance && origin ? { origin } : {};
|
||||
|
||||
const customSearchParamKeys = Object.keys(rest);
|
||||
const customSearchParams = customSearchParamKeys.reduce((validParams, paramKey) => {
|
||||
return customURLParams.includes(paramKey)
|
||||
? { ...validParams, ...validURLParamForExtendedData(paramKey, rest, customURLParamToConfig) }
|
||||
: { ...validParams };
|
||||
}, {});
|
||||
|
||||
return {
|
||||
...boundsMaybe,
|
||||
...originMaybe,
|
||||
...customSearchParams,
|
||||
};
|
||||
};
|
||||
|
||||
export const createSearchResultSchema = (listings, address, intl) => {
|
||||
// Schema for search engines (helps them to understand what this page is about)
|
||||
// http://schema.org
|
||||
// We are using JSON-LD format
|
||||
const siteTitle = config.siteTitle;
|
||||
const searchAddress = address || intl.formatMessage({ id: 'SearchPage.schemaMapSearch' });
|
||||
const schemaDescription = intl.formatMessage({ id: 'SearchPage.schemaDescription' });
|
||||
const schemaTitle = intl.formatMessage(
|
||||
{ id: 'SearchPage.schemaTitle' },
|
||||
{ searchAddress, siteTitle }
|
||||
);
|
||||
|
||||
const schemaListings = listings.map((l, i) => {
|
||||
const title = l.attributes.title;
|
||||
const pathToItem = createResourceLocatorString('ListingPage', routeConfiguration(), {
|
||||
id: l.id.uuid,
|
||||
slug: createSlug(title),
|
||||
});
|
||||
return {
|
||||
'@type': 'ListItem',
|
||||
position: i,
|
||||
url: `${config.canonicalRootURL}${pathToItem}`,
|
||||
name: title,
|
||||
};
|
||||
});
|
||||
|
||||
const schemaMainEntity = JSON.stringify({
|
||||
'@type': 'ItemList',
|
||||
name: searchAddress,
|
||||
itemListOrder: 'http://schema.org/ItemListOrderAscending',
|
||||
itemListElement: schemaListings,
|
||||
});
|
||||
return {
|
||||
title: schemaTitle,
|
||||
description: schemaDescription,
|
||||
schema: {
|
||||
'@context': 'http://schema.org',
|
||||
'@type': 'SearchResultsPage',
|
||||
description: schemaDescription,
|
||||
name: schemaTitle,
|
||||
mainEntity: [schemaMainEntity],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, injectIntl, intlShape } from 'react-intl';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import { connect } from 'react-redux';
|
||||
import { compose } from 'redux';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { debounce, intersection, isEqual, unionWith } from 'lodash';
|
||||
import { debounce, isEqual, unionWith } from 'lodash';
|
||||
import classNames from 'classnames';
|
||||
import config from '../../config';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
|
|
@ -15,85 +15,37 @@ import {
|
|||
hasSameSDKBounds,
|
||||
} from '../../util/googleMaps';
|
||||
import { createResourceLocatorString } from '../../util/routes';
|
||||
import { createSlug, parse, stringify } from '../../util/urlHelpers';
|
||||
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,
|
||||
SearchResultsPanel,
|
||||
SearchFilters,
|
||||
SearchFiltersMobile,
|
||||
SearchFiltersPanel,
|
||||
} from '../../components';
|
||||
import { SearchMap, ModalInMobile, Page } from '../../components';
|
||||
import { TopbarContainer } from '../../containers';
|
||||
import { searchListings, searchMapListings, setActiveListing } from './SearchPage.duck';
|
||||
|
||||
import { searchListings, searchMapListings, setActiveListing } from './SearchPage.duck';
|
||||
import {
|
||||
pickSearchParamsOnly,
|
||||
validURLParamForExtendedData,
|
||||
validURLParamsForExtendedData,
|
||||
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 MAX_SEARCH_RESULT_PAGE_SIZE_ON_MAP = 80; // max page size is 100 in API
|
||||
const MAX_SEARCH_RESULT_PAGES_ON_MAP = 1; // page size * n pages = number of listings shown on a map.
|
||||
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;
|
||||
|
||||
const CATEGORY_URL_PARAM = 'pub_category';
|
||||
const AMENITIES_URL_PARAM = 'pub_amenities';
|
||||
const USE_SEARCH_FILTER_PANEL = false;
|
||||
|
||||
// Find correct extended data key from config.custom
|
||||
// e.g. 'pub_category' -> 'categories'.
|
||||
const customConfigKey = paramKey => {
|
||||
switch (paramKey) {
|
||||
case CATEGORY_URL_PARAM:
|
||||
return 'categories';
|
||||
case AMENITIES_URL_PARAM:
|
||||
return 'amenities';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const validURLParamForExtendedData = (paramKey, urlParams) => {
|
||||
const configKey = customConfigKey(paramKey);
|
||||
const value = urlParams[paramKey];
|
||||
const valueArray = value ? value.split(',') : [];
|
||||
|
||||
if (configKey && valueArray.length > 0) {
|
||||
const allowedValues = config.custom[configKey].map(a => a.key);
|
||||
const validValues = intersection(valueArray, allowedValues).join(',');
|
||||
return validValues.length > 0 ? { [paramKey]: validValues } : {};
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
||||
// validate filter params
|
||||
const validURLParamsForExtendedData = params => {
|
||||
const { [CATEGORY_URL_PARAM]: category, [AMENITIES_URL_PARAM]: amenities, ...rest } = params;
|
||||
return {
|
||||
...rest,
|
||||
...validURLParamForExtendedData(CATEGORY_URL_PARAM, params),
|
||||
...validURLParamForExtendedData(AMENITIES_URL_PARAM, params),
|
||||
};
|
||||
};
|
||||
|
||||
// extract search parameters, including a custom attribute named category
|
||||
const pickSearchParamsOnly = params => {
|
||||
const { address, origin, bounds, country, ...rest } = params || {};
|
||||
const boundsMaybe = bounds ? { bounds } : {};
|
||||
const originMaybe = config.sortSearchByDistance && origin ? { origin } : {};
|
||||
return {
|
||||
...boundsMaybe,
|
||||
...originMaybe,
|
||||
...validURLParamForExtendedData(CATEGORY_URL_PARAM, rest),
|
||||
...validURLParamForExtendedData(AMENITIES_URL_PARAM, rest),
|
||||
};
|
||||
const customURLParamToConfig = {
|
||||
[CATEGORY_URL_PARAM]: 'categories',
|
||||
[AMENITIES_URL_PARAM]: 'amenities',
|
||||
};
|
||||
|
||||
export class SearchPageComponent extends Component {
|
||||
|
|
@ -103,7 +55,6 @@ export class SearchPageComponent extends Component {
|
|||
this.state = {
|
||||
isSearchMapOpenOnMobile: props.tab === 'map',
|
||||
isMobileModalOpen: false,
|
||||
isSearchFiltersPanelOpen: false,
|
||||
};
|
||||
|
||||
// Initiating map creates 'bounds_changes' event
|
||||
|
|
@ -116,19 +67,12 @@ export class SearchPageComponent extends Component {
|
|||
this.searchMapListingsInProgress = false;
|
||||
|
||||
this.onIdle = debounce(this.onIdle.bind(this), SEARCH_WITH_MAP_DEBOUNCE);
|
||||
this.fetchMoreListingsToMap = this.fetchMoreListingsToMap.bind(this);
|
||||
this.onOpenMobileModal = this.onOpenMobileModal.bind(this);
|
||||
this.onCloseMobileModal = this.onCloseMobileModal.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchMoreListingsToMap(this.props.location);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (!isEqual(this.props.location, nextProps.location)) {
|
||||
this.fetchMoreListingsToMap(nextProps.location);
|
||||
|
||||
// If no mapSearch url parameter is given, this is original location search
|
||||
const { mapSearch } = parse(nextProps.location.search, {
|
||||
latlng: ['origin'],
|
||||
|
|
@ -175,8 +119,8 @@ export class SearchPageComponent extends Component {
|
|||
bounds: viewportBounds,
|
||||
country,
|
||||
mapSearch: true,
|
||||
...validURLParamForExtendedData(CATEGORY_URL_PARAM, rest),
|
||||
...validURLParamForExtendedData(AMENITIES_URL_PARAM, rest),
|
||||
...validURLParamForExtendedData(CATEGORY_URL_PARAM, rest, customURLParamToConfig),
|
||||
...validURLParamForExtendedData(AMENITIES_URL_PARAM, rest, customURLParamToConfig),
|
||||
};
|
||||
this.viewportBounds = viewportBounds;
|
||||
history.push(
|
||||
|
|
@ -188,52 +132,6 @@ export class SearchPageComponent extends Component {
|
|||
}
|
||||
}
|
||||
|
||||
fetchMoreListingsToMap(location) {
|
||||
// TODO Remove this function.
|
||||
// Temporarily just return immediately to avoid merge conflicts.
|
||||
return;
|
||||
|
||||
// eslint-disable-next-line no-unreachable
|
||||
const { onSearchMapListings } = this.props;
|
||||
const searchInURL = parse(location.search, {
|
||||
latlng: ['origin'],
|
||||
latlngBounds: ['bounds'],
|
||||
});
|
||||
|
||||
const perPage = MAX_SEARCH_RESULT_PAGE_SIZE_ON_MAP;
|
||||
const page = 1;
|
||||
const { address, country, origin, ...rest } = searchInURL;
|
||||
const originMaybe = config.sortSearchByDistance ? { origin } : {};
|
||||
const searchParamsForMapResults = {
|
||||
...rest,
|
||||
...originMaybe,
|
||||
include: ['images'],
|
||||
page,
|
||||
perPage,
|
||||
'fields.image': ['variants.landscape-crop', 'variants.landscape-crop2x'],
|
||||
'limit.images': 1,
|
||||
};
|
||||
this.searchMapListingsInProgress = true;
|
||||
|
||||
// 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 });
|
||||
} else {
|
||||
this.searchMapListingsInProgress = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
// In case of error, stop recursive loop and report error.
|
||||
// TODO: Show and error in the listings column
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`An error (${error} occured while trying to retrieve map listings`);
|
||||
});
|
||||
}
|
||||
|
||||
// Invoked when a modal is opened from a child component,
|
||||
// for example when a filter modal is opened in mobile view
|
||||
onOpenMobileModal() {
|
||||
|
|
@ -271,77 +169,33 @@ export class SearchPageComponent extends Component {
|
|||
|
||||
// urlQueryParams doesn't contain page specific url params
|
||||
// like mapSearch, page or origin (origin depends on config.sortSearchByDistance)
|
||||
const urlQueryParams = pickSearchParamsOnly(searchInURL);
|
||||
const urlQueryParams = pickSearchParamsOnly(
|
||||
searchInURL,
|
||||
[AMENITIES_URL_PARAM, CATEGORY_URL_PARAM],
|
||||
customURLParamToConfig
|
||||
);
|
||||
|
||||
// Page transition might initially use values from previous search
|
||||
const urlQueryString = stringify(urlQueryParams);
|
||||
const paramsQueryString = stringify(pickSearchParamsOnly(searchParams));
|
||||
const searchParamsMatch = urlQueryString === paramsQueryString;
|
||||
const paramsQueryString = stringify(
|
||||
pickSearchParamsOnly(
|
||||
searchParams,
|
||||
[AMENITIES_URL_PARAM, CATEGORY_URL_PARAM],
|
||||
customURLParamToConfig
|
||||
)
|
||||
);
|
||||
const searchParamsAreInSync = urlQueryString === paramsQueryString;
|
||||
|
||||
const { address, bounds, origin } = searchInURL || {};
|
||||
|
||||
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
|
||||
const totalItems = searchParamsMatch && hasPaginationInfo ? pagination.totalItems : 0;
|
||||
const listingsAreLoaded = !searchInProgress && searchParamsMatch && hasPaginationInfo;
|
||||
|
||||
const validQueryParams = validURLParamsForExtendedData(searchInURL);
|
||||
|
||||
const searchError = (
|
||||
<h2 className={css.error}>
|
||||
<FormattedMessage id="SearchPage.searchError" />
|
||||
</h2>
|
||||
const validQueryParams = validURLParamsForExtendedData(
|
||||
searchInURL,
|
||||
[AMENITIES_URL_PARAM, CATEGORY_URL_PARAM],
|
||||
customURLParamToConfig
|
||||
);
|
||||
|
||||
const searchMap = (
|
||||
<SearchMap
|
||||
activeListingId={activeListingId}
|
||||
bounds={bounds}
|
||||
center={origin}
|
||||
listings={mapListings || []}
|
||||
onIdle={this.onIdle}
|
||||
isOpenOnModal={this.state.isSearchMapOpenOnMobile}
|
||||
onCloseAsModal={() => {
|
||||
onManageDisableScrolling('SearchPage.map', false);
|
||||
}}
|
||||
useLocationSearchBounds={!this.viewportBounds}
|
||||
/>
|
||||
);
|
||||
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);
|
||||
|
||||
// Schema for search engines (helps them to understand what this page is about)
|
||||
// http://schema.org
|
||||
// We are using JSON-LD format
|
||||
const siteTitle = config.siteTitle;
|
||||
const searchAddress = address || intl.formatMessage({ id: 'SearchPage.schemaMapSearch' });
|
||||
const schemaTitle = intl.formatMessage(
|
||||
{ id: 'SearchPage.schemaTitle' },
|
||||
{ searchAddress, siteTitle }
|
||||
);
|
||||
const schemaDescription = intl.formatMessage({ id: 'SearchPage.schemaDescription' });
|
||||
const schemaListings = listings.map((l, i) => {
|
||||
const title = l.attributes.title;
|
||||
const pathToItem = createResourceLocatorString('ListingPage', routeConfiguration(), {
|
||||
id: l.id.uuid,
|
||||
slug: createSlug(title),
|
||||
});
|
||||
return {
|
||||
'@type': 'ListItem',
|
||||
position: i,
|
||||
url: `${config.canonicalRootURL}${pathToItem}`,
|
||||
name: title,
|
||||
};
|
||||
});
|
||||
const schemaMainEntity = JSON.stringify({
|
||||
'@type': 'ItemList',
|
||||
name: searchAddress,
|
||||
itemListOrder: 'http://schema.org/ItemListOrderAscending',
|
||||
itemListElement: schemaListings,
|
||||
});
|
||||
const isMobileLayout = isWindowDefined && window.innerWidth < MODAL_BREAKPOINT;
|
||||
const shouldShowSearchMap =
|
||||
!isMobileLayout || (isMobileLayout && this.state.isSearchMapOpenOnMobile);
|
||||
|
||||
const onMapIconClick = () => {
|
||||
this.useLocationSearchBounds = true;
|
||||
|
|
@ -349,51 +203,8 @@ export class SearchPageComponent extends Component {
|
|||
this.setState({ isSearchMapOpenOnMobile: true });
|
||||
};
|
||||
|
||||
const extraSearchFiltersPanelOrListings =
|
||||
USE_SEARCH_FILTER_PANEL && this.state.isSearchFiltersPanelOpen ? (
|
||||
<div className={classNames(css.searchFiltersPanel)}>
|
||||
<SearchFiltersPanel
|
||||
urlQueryParams={validQueryParams}
|
||||
listingsAreLoaded={listingsAreLoaded}
|
||||
categories={categories}
|
||||
amenities={amenities}
|
||||
onClosePanel={() => this.setState({ isSearchFiltersPanelOpen: false })}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={classNames(css.listings, {
|
||||
[css.newSearchInProgress]: !listingsAreLoaded,
|
||||
})}
|
||||
>
|
||||
{searchListingsError ? searchError : null}
|
||||
<SearchResultsPanel
|
||||
className={css.searchListingsPanel}
|
||||
listings={listings}
|
||||
pagination={listingsAreLoaded ? pagination : null}
|
||||
search={searchParamsForPagination}
|
||||
setActiveListing={onActivateListing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// An example how to check how many filters are selected on SearchFilterPanel
|
||||
// if it is in use.
|
||||
//
|
||||
// const searchFiltersPanelSelectedCount = [
|
||||
// validQueryParams[FILTER_1_URL_PARAM],
|
||||
// validQueryParams[FILTER_2_URL_PARAM],
|
||||
// ].filter(param => !!param).length
|
||||
const searchFiltersPanelSelectedCount = 0;
|
||||
const searchFiltersPanelProps = USE_SEARCH_FILTER_PANEL
|
||||
? {
|
||||
isSearchFiltersPanelOpen: this.state.isSearchFiltersPanelOpen,
|
||||
toggleSearchFiltersPanel: isOpen => {
|
||||
this.setState({ isSearchFiltersPanelOpen: isOpen });
|
||||
},
|
||||
searchFiltersPanelSelectedCount,
|
||||
}
|
||||
: {};
|
||||
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
|
||||
|
|
@ -407,15 +218,9 @@ export class SearchPageComponent extends Component {
|
|||
return (
|
||||
<Page
|
||||
scrollingDisabled={scrollingDisabled}
|
||||
description={schemaDescription}
|
||||
title={schemaTitle}
|
||||
schema={{
|
||||
'@context': 'http://schema.org',
|
||||
'@type': 'SearchResultsPage',
|
||||
description: schemaDescription,
|
||||
name: schemaTitle,
|
||||
mainEntity: [schemaMainEntity],
|
||||
}}
|
||||
description={description}
|
||||
title={title}
|
||||
schema={schema}
|
||||
>
|
||||
<TopbarContainer
|
||||
className={topbarClasses}
|
||||
|
|
@ -423,36 +228,22 @@ export class SearchPageComponent extends Component {
|
|||
currentSearchParams={urlQueryParams}
|
||||
/>
|
||||
<div className={css.container}>
|
||||
<div className={css.searchResultContainer}>
|
||||
<SearchFilters
|
||||
className={css.searchFilters}
|
||||
urlQueryParams={validQueryParams}
|
||||
listingsAreLoaded={listingsAreLoaded}
|
||||
resultsCount={totalItems}
|
||||
searchInProgress={searchInProgress}
|
||||
searchListingsError={searchListingsError}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
categories={categories}
|
||||
amenities={amenities}
|
||||
{...searchFiltersPanelProps}
|
||||
/>
|
||||
<SearchFiltersMobile
|
||||
className={css.searchFiltersMobile}
|
||||
urlQueryParams={validQueryParams}
|
||||
listingsAreLoaded={listingsAreLoaded}
|
||||
resultsCount={totalItems}
|
||||
searchInProgress={searchInProgress}
|
||||
searchListingsError={searchListingsError}
|
||||
showAsModalMaxWidth={MODAL_BREAKPOINT}
|
||||
onMapIconClick={onMapIconClick}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onOpenModal={this.onOpenMobileModal}
|
||||
onCloseModal={this.onCloseMobileModal}
|
||||
categories={categories}
|
||||
amenities={amenities}
|
||||
/>
|
||||
{extraSearchFiltersPanelOrListings}
|
||||
</div>
|
||||
<MainPanel
|
||||
urlQueryParams={validQueryParams}
|
||||
listings={listings}
|
||||
searchInProgress={searchInProgress}
|
||||
searchListingsError={searchListingsError}
|
||||
searchParamsAreInSync={searchParamsAreInSync}
|
||||
onActivateListing={onActivateListing}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
onMapIconClick={onMapIconClick}
|
||||
pagination={pagination}
|
||||
searchParamsForPagination={parse(location.search)}
|
||||
showAsModalMaxWidth={MODAL_BREAKPOINT}
|
||||
customURLParamToConfig={customURLParamToConfig}
|
||||
primaryFilters={{ amenities, categories }}
|
||||
secondaryFilters={{ amenities, categories }}
|
||||
/>
|
||||
<ModalInMobile
|
||||
className={css.mapPanel}
|
||||
id="SearchPage.map"
|
||||
|
|
@ -461,7 +252,22 @@ export class SearchPageComponent extends Component {
|
|||
showAsModalMaxWidth={MODAL_BREAKPOINT}
|
||||
onManageDisableScrolling={onManageDisableScrolling}
|
||||
>
|
||||
<div className={css.map}>{searchMapMaybe}</div>
|
||||
<div className={css.map}>
|
||||
{shouldShowSearchMap ? (
|
||||
<SearchMap
|
||||
activeListingId={activeListingId}
|
||||
bounds={bounds}
|
||||
center={origin}
|
||||
listings={mapListings || []}
|
||||
onIdle={this.onIdle}
|
||||
isOpenOnModal={this.state.isSearchMapOpenOnMobile}
|
||||
onCloseAsModal={() => {
|
||||
onManageDisableScrolling('SearchPage.map', false);
|
||||
}}
|
||||
useLocationSearchBounds={!this.viewportBounds}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</ModalInMobile>
|
||||
</div>
|
||||
</Page>
|
||||
|
|
@ -487,6 +293,7 @@ const { array, bool, func, oneOf, object, shape, string } = PropTypes;
|
|||
SearchPageComponent.propTypes = {
|
||||
listings: array,
|
||||
mapListings: array,
|
||||
onActivateListing: func.isRequired,
|
||||
onManageDisableScrolling: func.isRequired,
|
||||
onSearchMapListings: func.isRequired,
|
||||
pagination: propTypes.pagination,
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ describe('SearchPageComponent', () => {
|
|||
currentUserHasListings: false,
|
||||
intl: fakeIntl,
|
||||
isAuthenticated: false,
|
||||
onActivateListing: noop,
|
||||
onLogout: noop,
|
||||
onManageDisableScrolling: noop,
|
||||
onSearchMapListings: noop,
|
||||
|
|
|
|||
|
|
@ -22,10 +22,29 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
currentSearchParams={Object {}}
|
||||
/>
|
||||
<div>
|
||||
<div>
|
||||
<withRouter(InjectIntl(SearchFiltersComponent))
|
||||
amenities={
|
||||
Array [
|
||||
<MainPanel
|
||||
className={null}
|
||||
customURLParamToConfig={
|
||||
Object {
|
||||
"pub_amenities": "amenities",
|
||||
"pub_category": "categories",
|
||||
}
|
||||
}
|
||||
listings={Array []}
|
||||
onActivateListing={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onMapIconClick={[Function]}
|
||||
pagination={
|
||||
Object {
|
||||
"page": 1,
|
||||
"perPage": 12,
|
||||
"totalItems": 22,
|
||||
"totalPages": 2,
|
||||
}
|
||||
}
|
||||
primaryFilters={
|
||||
Object {
|
||||
"amenities": Array [
|
||||
Object {
|
||||
"key": "dog1",
|
||||
"label": "Dog 1",
|
||||
|
|
@ -34,10 +53,8 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
"key": "dog2",
|
||||
"label": "Dog 2",
|
||||
},
|
||||
]
|
||||
}
|
||||
categories={
|
||||
Array [
|
||||
],
|
||||
"categories": Array [
|
||||
Object {
|
||||
"key": "cat1",
|
||||
"label": "Cat 1",
|
||||
|
|
@ -46,18 +63,18 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
"key": "cat2",
|
||||
"label": "Cat 2",
|
||||
},
|
||||
]
|
||||
],
|
||||
}
|
||||
listingsAreLoaded={true}
|
||||
onManageDisableScrolling={[Function]}
|
||||
resultsCount={22}
|
||||
searchInProgress={false}
|
||||
searchListingsError={null}
|
||||
urlQueryParams={Object {}}
|
||||
/>
|
||||
<InjectIntl(withRouter(SearchFiltersMobileComponent))
|
||||
amenities={
|
||||
Array [
|
||||
}
|
||||
resultsCount={0}
|
||||
rootClassName={null}
|
||||
searchInProgress={false}
|
||||
searchListingsError={null}
|
||||
searchParamsAreInSync={true}
|
||||
searchParamsForPagination={Object {}}
|
||||
secondaryFilters={
|
||||
Object {
|
||||
"amenities": Array [
|
||||
Object {
|
||||
"key": "dog1",
|
||||
"label": "Dog 1",
|
||||
|
|
@ -66,10 +83,8 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
"key": "dog2",
|
||||
"label": "Dog 2",
|
||||
},
|
||||
]
|
||||
}
|
||||
categories={
|
||||
Array [
|
||||
],
|
||||
"categories": Array [
|
||||
Object {
|
||||
"key": "cat1",
|
||||
"label": "Cat 1",
|
||||
|
|
@ -78,38 +93,12 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
"key": "cat2",
|
||||
"label": "Cat 2",
|
||||
},
|
||||
]
|
||||
],
|
||||
}
|
||||
listingsAreLoaded={true}
|
||||
onCloseModal={[Function]}
|
||||
onManageDisableScrolling={[Function]}
|
||||
onMapIconClick={[Function]}
|
||||
onOpenModal={[Function]}
|
||||
resultsCount={22}
|
||||
searchInProgress={false}
|
||||
searchListingsError={null}
|
||||
showAsModalMaxWidth={768}
|
||||
urlQueryParams={Object {}}
|
||||
/>
|
||||
<div
|
||||
className=""
|
||||
>
|
||||
<SearchResultsPanel
|
||||
className={null}
|
||||
listings={Array []}
|
||||
pagination={
|
||||
Object {
|
||||
"page": 1,
|
||||
"perPage": 12,
|
||||
"totalItems": 22,
|
||||
"totalPages": 2,
|
||||
}
|
||||
}
|
||||
rootClassName={null}
|
||||
search={Object {}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
showAsModalMaxWidth={768}
|
||||
urlQueryParams={Object {}}
|
||||
/>
|
||||
<withViewport(ModalInMobileComponent)
|
||||
id="SearchPage.map"
|
||||
isModalOpenOnMobile={false}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue