Merge pull request #307 from sharetribe/searchpage-desktop

Searchpage desktop
This commit is contained in:
Vesa Luusua 2017-07-18 19:30:15 +03:00 committed by GitHub
commit 861be091b2
21 changed files with 849 additions and 154 deletions

View file

@ -0,0 +1,82 @@
@import '../../marketplace.css';
.root {
position: relative;
transform: translate( -50%, -33px);
width: auto;
height: auto;
}
.priceLabel {
/**
* Since caret is absolutely positioned,
* label must have relative to be included to the same rendering layer
*/
position: relative;
/* Font */
@apply --marketplaceH5FontStyles;
font-weight: var(--fontWeightSemiBold);
color: var(--matterColor);
background-color: var(--matterColorLight);
/* Borders */
border-style: solid;
border-color: var(--matterColorNegative);
border-width: 1px;
border-radius: 4px;
box-shadow: var(--boxShadowPopupLight);
/* Dimensions */
padding: 10px 10px 8px 10px;
margin-top: 0;
margin-bottom: 0;
&:hover {
color: var(--marketplaceColor);
}
/* Overwrite dimensions from font styles */
@media (--desktopViewport) {
margin-top: 0;
margin-bottom: 0;
}
}
.caretShadow {
/* Caret / arrow dimensions and position */
width: 10px;
height: 10px;
position: absolute;
bottom: -5px;
left: 50%;
margin-left: -5px;
transform: rotate(45deg);
/* Caret should have same box-shadow as label */
box-shadow: var(--boxShadowPopupLight);
}
.caret {
/* Caret / arrow dimensions and position */
width: 10px;
height: 10px;
position: absolute;
bottom: -5px;
left: 50%;
margin-left: -5px;
transform: rotate(45deg);
/* Caret should have same bg-color and border as label */
background-color: var(--matterColorLight);
border-right-style: solid;
border-right-color: var(--matterColorNegative);
border-right-width: 1px;
border-bottom-style: solid;
border-bottom-color: var(--matterColorNegative);
border-bottom-width: 1px;
}

View file

@ -0,0 +1,63 @@
/**
* MapPriceMarker is build on top of google.maps.OverlayView so that we can actually render HTML
* to the map. Price label's width varies and therefore we can't use default fixed-size markers.
*/
import React, { PropTypes } from 'react';
import { intlShape, injectIntl } from 'react-intl';
import config from '../../config';
import * as propTypes from '../../util/propTypes';
import { convertMoneyToNumber } from '../../util/currency';
import OverlayLayer from './OverlayLayer';
import css from './MapPriceMarker.css';
const priceLabel = (price, currencyConfig, intl) => {
if (price && price.currency === currencyConfig.currency) {
const priceAsNumber = convertMoneyToNumber(price, currencyConfig.subUnitDivisor);
return intl.formatNumber(priceAsNumber, currencyConfig);
} else if (price) {
return intl.formatMessage(
{ id: 'MapPriceMarker.unsupportedPrice' },
{ currency: price.currency }
);
}
return null;
};
const MapPriceMarker = props => {
const { intl, listing, map, onAddOverlay } = props;
const { price, geolocation } = listing.attributes;
const label = priceLabel(price, config.currencyConfig, intl);
return (
<OverlayLayer
id={listing.id.uuid}
map={map}
geolocation={geolocation}
onAddOverlay={onAddOverlay}
>
<div className={css.root}>
<div className={css.caretShadow} />
<div className={css.priceLabel}>
{label}
</div>
<div className={css.caret} />
</div>
</OverlayLayer>
);
};
MapPriceMarker.defaultProps = {
map: null,
};
const { func, object } = PropTypes;
MapPriceMarker.propTypes = {
intl: intlShape.isRequired,
listing: propTypes.listing.isRequired,
map: object,
onAddOverlay: func.isRequired,
};
export default injectIntl(MapPriceMarker);

View file

@ -0,0 +1,124 @@
import { Component, PropTypes } from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
class OverlayLayer extends Component {
constructor(props) {
super(props);
this.onAdd = this.onAdd.bind(this);
this.onRemove = this.onRemove.bind(this);
this.draw = this.draw.bind(this);
this.overlayContainer = null;
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#OverlayView
const overlayView = new window.google.maps.OverlayView();
// As stated by Google these three methods must implemented: onAdd(), draw(), and onRemove().
overlayView.onAdd = this.onAdd;
overlayView.draw = this.draw;
overlayView.onRemove = this.onRemove;
// You must call setMap() with a valid Map object to trigger the call to
// the onAdd() method and setMap(null) in order to trigger the onRemove() method.
overlayView.setMap(props.map);
this.state = { overlayView };
}
componentWillMount() {
const onAddOverlay = this.props.onAddOverlay;
if (onAddOverlay) {
// If onAddOverlay is saving anything to parent component's state,
// it must be called here to avoid warnings.
onAddOverlay(this.state.overlayView);
}
}
componentDidUpdate() {
this.draw();
}
componentWillUnmount() {
const overlayView = this.state.overlayView;
if (overlayView) {
overlayView.setMap(null);
overlayView.onAdd = null;
overlayView.draw = null;
overlayView.onRemove = null;
}
}
onAdd() {
// Create container for this custom "marker"
this.overlayContainer = document.createElement(`div`);
this.overlayContainer.style.position = `absolute`;
this.overlayContainer.dataset.overlayId = this.props.id;
}
onRemove() {
// Remove container from parent node and tell React about it too
this.overlayContainer.parentNode.removeChild(this.overlayContainer);
unmountComponentAtNode(this.overlayContainer);
}
draw() {
const overlayView = this.state.overlayView;
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapCanvasProjection
const overlayProjection = overlayView.getProjection();
// render children to overlayContainer.
if (this.overlayContainer) {
this.overlayContainer.innerHTML = '';
render(this.props.children, this.overlayContainer);
}
// Add the element to the "overlayMouseTarget" pane.
// https://developers.google.com/maps/documentation/javascript/3.exp/reference#MapPanes
const mapPanes = this.state.overlayView.getPanes();
if (mapPanes && this.overlayContainer) {
const geolocation = this.props.geolocation;
const googleLatLng = new window.google.maps.LatLng({
lat: geolocation.lat,
lng: geolocation.lng,
});
const point = overlayProjection.fromLatLngToDivPixel(googleLatLng);
if (point) {
this.overlayContainer.style.left = `${point.x}px`;
this.overlayContainer.style.top = `${point.y}px`;
this.overlayContainer.style.height = `0`;
}
// overlayMouseTarget might not be a correct pane - overlayLayer is used by some services
mapPanes.overlayMouseTarget.appendChild(this.overlayContainer);
window.google.maps.event.addDomListener(this.overlayContainer, 'click', event => {
// TODO: Click handler needs to open info window at some point
// eslint-disable-next-line no-console
console.log(
`OverlayLayer (${event.target.closest('[data-overlay-id]').dataset.overlayId}) clicked.`
);
window.google.maps.event.trigger(self, 'click');
});
}
}
render() {
return null;
}
}
OverlayLayer.defaultProps = { children: null, map: null, onAddOverlay: null };
const { func, node, number, object, shape, string } = PropTypes;
OverlayLayer.propTypes = {
children: node,
geolocation: shape({ lat: number, lng: number }).isRequired,
id: string.isRequired,
map: object,
onAddOverlay: func,
};
export default OverlayLayer;

View file

@ -38,7 +38,9 @@ class MenuLabel extends Component {
const { children, className, rootClassName } = this.props;
const rootClass = rootClassName || css.root;
const classes = classNames(rootClass, className, { [css.clickedWithMouse]: this.state.clickedWithMouse });
const classes = classNames(rootClass, className, {
[css.clickedWithMouse]: this.state.clickedWithMouse,
});
return (
<button className={classes} onClick={this.onClick} onBlur={this.onBlur}>
@ -49,7 +51,12 @@ class MenuLabel extends Component {
}
/* eslint-enable jsx-a11y/no-static-element-interactions */
MenuLabel.defaultProps = { className: null, isOpen: false, onToggleActive: null, rootClassName: '' };
MenuLabel.defaultProps = {
className: null,
isOpen: false,
onToggleActive: null,
rootClassName: '',
};
const { func, node, string } = PropTypes;

View file

@ -3,7 +3,6 @@
/* Content is visible as modal layer */
.isOpen {
display: flex;
flex: 1;
flex-direction: column;
position: fixed;
top: 0;

View file

@ -0,0 +1,4 @@
.root {
width: 100%;
height: 100%;
}

View file

@ -0,0 +1,129 @@
import React, { Component, PropTypes } from 'react';
import { isEqualWith, sortBy } from 'lodash';
import classNames from 'classnames';
import { types as sdkTypes } from '../../util/sdkLoader';
import * as propTypes from '../../util/propTypes';
import { MapPriceMarker } from '../../components';
import css from './SearchMap.css';
const fitMapToBounds = (map, bounds) => {
const { ne, sw } = bounds || {};
// map bounds as string literal for google.maps
const mapBounds = bounds ? { north: ne.lat, east: ne.lng, south: sw.lat, west: sw.lng } : null;
if (mapBounds) {
map.fitBounds(mapBounds);
}
};
// Compare listing arrays (if listings are the same, there's no need to rerender google maps).
const hasSameListings = (prevListings, nextListings) => {
return isEqualWith(sortBy(prevListings, l => l.id.uuid), sortBy(nextListings, l => l.id.uuid));
};
class SearchMap extends Component {
constructor(props) {
super(props);
this.map = null;
this.state = { overlays: [] };
this.addOverlay = this.addOverlay.bind(this);
}
componentDidMount() {
const mapsLibLoaded = window.google && window.google.maps;
if (!mapsLibLoaded) {
throw new Error('Google Maps API must be loaded for the SearchMap component');
}
const { bounds, center, zoom } = this.props;
const centerLocation = { lat: center.lat, lng: center.lng };
const mapOptions = {
center: centerLocation,
zoom,
// Disable map type (ie. Satellite etc.)
mapTypeControl: false,
// Disable zooming by scrolling
scrollwheel: false,
};
this.map = new window.google.maps.Map(this.el, mapOptions);
// If bounds are given, use it (defaults to center & zoom).
fitMapToBounds(this.map, bounds);
}
componentWillReceiveProps(nextProps) {
const { bounds, listings } = nextProps;
fitMapToBounds(this.map, bounds);
if (!hasSameListings(listings, nextProps.listings)) {
// Clear markers from map
this.state.overlays.forEach(o => o.setMap(null));
}
}
addOverlay(overlay) {
this.setState(prevState => {
// Track map overlays, in case we need to clear them from map
const overlays = prevState.overlays.concat([overlay]);
return { overlays };
});
}
render() {
const { className, rootClassName, listings } = this.props;
const classes = classNames(rootClassName || css.root, className);
// Add listing markers to map if listings prop is passed
// By reversing the order, we can show the nearest price labels on top of more distant ones.
const currentMarkers = listings
.map(l => {
return (
<MapPriceMarker
key={l.id.uuid}
map={this.map}
listing={l}
onAddOverlay={this.addOverlay}
/>
);
})
.reverse();
return (
<div
className={classes}
ref={el => {
this.el = el;
}}
>
{currentMarkers}
</div>
);
}
}
SearchMap.defaultProps = {
className: '',
rootClassName: null,
bounds: null,
center: new sdkTypes.LatLng(0, 0),
listings: [],
zoom: 11,
};
const { arrayOf, number, string } = PropTypes;
SearchMap.propTypes = {
bounds: propTypes.latlngBounds,
center: propTypes.latlng,
className: string,
listings: arrayOf(propTypes.listing),
rootClassName: string,
zoom: number,
};
export default SearchMap;

View file

@ -7,10 +7,69 @@
.listingCards {
padding: 0 24px 24px 24px;
@media screen and (min-width: 768px) {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
}
.listingCard {
margin-bottom: 36px;
@media screen and (min-width: 768px) {
flex-basis: calc(100%);
/* ListingCard's minimum width on desktop */
min-width: 264px;
}
@media screen and (min-width: 1000px) {
/**
* resultPanelWidthRatio = 0.6 aka 60%
* ((columnCount * ListingCardWidth + gutter * (columnCount + 1)) / resultPanelWidthRatio = 1000
* min-width: (2 * 264 + 24 * 3) / 0.6 = 1000
* flex-basis: calc((100%/columnCount) - (guttersBetweenColumns * gutterWidth / columnCount))
*/
flex-basis: calc(50% - 12px);
margin-right: 24px;
min-width: 264px;
}
@media screen and (min-width: 1480px) {
/* (3*264 + 24 * 4) / 0.6 = 1420 */
flex-basis: calc(33.33% - 16px);
margin-right: 24px;
min-width: 264px;
}
@media screen and (min-width: 1960px) {
/* (4*264 + 24 * 5) / 0.6 = 1960 */
flex-basis: calc(25% - 18px);
margin-right: 24px;
min-width: 264px;
}
}
/* Remove margin-right from listingCards on the last column */
.listingCard:nth-of-type(2n) {
@media screen and (min-width: 1000px) and (max-width: 1479px) {
margin-right: 0;
}
}
.listingCard:nth-of-type(3n) {
@media screen and (min-width: 1480px) and (max-width: 1959px) {
margin-right: 0;
}
}
.listingCard:nth-of-type(4n) {
@media screen and (min-width: 1960px) {
margin-right: 0;
}
}
.pagination {

View file

@ -23,6 +23,7 @@ import LocationAutocompleteInput, {
} from './LocationAutocompleteInput/LocationAutocompleteInput';
import Map from './Map/Map';
import MapPanel from './MapPanel/MapPanel';
import MapPriceMarker from './MapPriceMarker/MapPriceMarker';
import Menu from './Menu/Menu';
import MenuContent from './MenuContent/MenuContent';
import MenuItem from './MenuItem/MenuItem';
@ -41,6 +42,7 @@ import ResponsiveImage from './ResponsiveImage/ResponsiveImage';
import RoutesProvider from './RoutesProvider/RoutesProvider';
import SaleDetailsPanel from './SaleDetailsPanel/SaleDetailsPanel';
import SearchIcon from './SearchIcon/SearchIcon';
import SearchMap from './SearchMap/SearchMap';
import SearchResultsPanel from './SearchResultsPanel/SearchResultsPanel';
import SelectField from './SelectField/SelectField';
import StripeBankAccountTokenInputField
@ -81,6 +83,7 @@ export {
LocationAutocompleteInputField,
Map,
MapPanel,
MapPriceMarker,
Menu,
MenuContent,
MenuItem,
@ -100,6 +103,7 @@ export {
RoutesProvider,
SaleDetailsPanel,
SearchIcon,
SearchMap,
SearchResultsPanel,
SecondaryButton,
SelectField,

View file

@ -58,7 +58,8 @@ export class EditListingPhotosFormComponent extends Component {
const file = event.target.files[0];
if (file) {
this.setState({ imageUploadRequested: true });
this.props.onImageUpload({ id: `${file.name}_${Date.now()}`, file })
this.props
.onImageUpload({ id: `${file.name}_${Date.now()}`, file })
.then(() => {
this.setState({ imageUploadRequested: false });
})
@ -164,11 +165,7 @@ export class EditListingPhotosFormComponent extends Component {
{createListingFailed}
{showListingFailed}
<Button
className={css.submitButton}
type="submit"
disabled={disableForm}
>
<Button className={css.submitButton} type="submit" disabled={disableForm}>
{saveActionMsg}
</Button>
</form>

View file

@ -24,7 +24,11 @@ export const EditProfilePageComponent = props => {
} = props;
return (
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title={`Edit profile page with display name: ${params.displayName}`}>
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title={`Edit profile page with display name: ${params.displayName}`}
>
<Topbar
authInProgress={authInProgress}
currentUser={currentUser}

View file

@ -48,7 +48,7 @@ export class NotFoundPageComponent extends Component {
</PageLayout>
);
}
};
}
NotFoundPageComponent.defaultProps = {
authInfoError: null,

View file

@ -29,7 +29,11 @@ export const PasswordForgottenPageComponent = props => {
} = props;
return (
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title="Request new password">
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title="Request new password"
>
<Topbar
authInProgress={authInProgress}
currentUser={currentUser}

View file

@ -24,7 +24,11 @@ export const ProfilePageComponent = props => {
} = props;
return (
<PageLayout authInfoError={authInfoError} logoutError={logoutError} title={`Profile page with display name: ${params.displayName}`}>
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title={`Profile page with display name: ${params.displayName}`}
>
<Topbar
authInProgress={authInProgress}
currentUser={currentUser}

View file

@ -1,10 +1,44 @@
@import '../../marketplace.css';
:root {
--containerHeight: calc(100vh - var(--topbarHeightDesktop));
}
.topbar {
@media (--desktopViewport) {
position: fixed;
top: 0;
left: 0;
/* We need to raise Topbar above .container */
z-index: 1;
}
}
.container {
/* Layout */
width: 100%;
flex-grow: 1;
display: flex;
@media (--desktopViewport) {
position: relative;
padding-top: var(--topbarHeightDesktop);
min-height: var(--containerHeight);
}
}
.searchResultContainer {
flex-grow: 1;
display: flex;
flex-direction: column;
@media (--desktopViewport) {
/**
* .container is using flexbox,
* This specifies that searchResultContainer is taking 60% from the viewport width
*/
flex-basis: 60%;
}
}
.searchResultSummary {
@ -17,6 +51,7 @@
}
.searchString {
/* Search string should not break on white spaces - i.e. line-break should happen before. */
white-space: nowrap;
}
@ -29,3 +64,33 @@
.searchListingsPanel {
flex-grow: 1;
}
.mapPanel {
display: none;
@media (--desktopViewport) {
/**
* .container is using flexbox,
* This specifies that mapPanel is taking 40% from the viewport width
*/
flex-basis: 40%;
/* Own layout settings */
display: flex;
height: 100%;
}
}
.map {
@media (--desktopViewport) {
/* Map is fixed so that it doesn't scroll along search results */
position: fixed;
top: var(--topbarHeightDesktop);
right: 0;
/* Fixed content needs width relative to viewport */
width: 40vw;
height: var(--containerHeight);
}
}

View file

@ -6,6 +6,10 @@ export const SEARCH_LISTINGS_REQUEST = 'app/SearchPage/SEARCH_LISTINGS_REQUEST';
export const SEARCH_LISTINGS_SUCCESS = 'app/SearchPage/SEARCH_LISTINGS_SUCCESS';
export const SEARCH_LISTINGS_ERROR = 'app/SearchPage/SEARCH_LISTINGS_ERROR';
export const SEARCH_MAP_LISTINGS_REQUEST = 'app/SearchPage/SEARCH_MAP_LISTINGS_REQUEST';
export const SEARCH_MAP_LISTINGS_SUCCESS = 'app/SearchPage/SEARCH_MAP_LISTINGS_SUCCESS';
export const SEARCH_MAP_LISTINGS_ERROR = 'app/SearchPage/SEARCH_MAP_LISTINGS_ERROR';
// ================ Reducer ================ //
const initialState = {
@ -14,6 +18,8 @@ const initialState = {
searchInProgress: false,
searchListingsError: null,
currentPageResultIds: [],
searchMapListingIds: [],
searchMapListingsError: null,
};
const resultIds = data => data.data.map(l => l.id);
@ -40,6 +46,23 @@ const listingPageReducer = (state = initialState, action = {}) => {
// eslint-disable-next-line no-console
console.error(payload);
return { ...state, searchInProgress: false, searchListingsError: payload };
case SEARCH_MAP_LISTINGS_REQUEST:
return {
...state,
//searchMapListingIds: [],
searchMapListingsError: null,
};
case SEARCH_MAP_LISTINGS_SUCCESS:
return {
...state,
searchMapListingIds: state.searchMapListingIds.concat(resultIds(payload.data)),
};
case SEARCH_MAP_LISTINGS_ERROR:
// eslint-disable-next-line no-console
console.error(payload);
return { ...state, searchMapListingsError: payload };
default:
return state;
}
@ -65,6 +88,19 @@ export const searchListingsError = e => ({
payload: e,
});
export const searchMapListingsRequest = () => ({ type: SEARCH_MAP_LISTINGS_REQUEST });
export const searchMapListingsSuccess = response => ({
type: SEARCH_MAP_LISTINGS_SUCCESS,
payload: { data: response.data },
});
export const searchMapListingsError = e => ({
type: SEARCH_MAP_LISTINGS_ERROR,
error: true,
payload: e,
});
export const searchListings = searchParams =>
(dispatch, getState, sdk) => {
dispatch(searchListingsRequest(searchParams));
@ -87,3 +123,26 @@ export const searchListings = searchParams =>
throw e;
});
};
export const searchMapListings = searchParams =>
(dispatch, getState, sdk) => {
dispatch(searchMapListingsRequest(searchParams));
const { origin, include = [], page = 1, perPage } = searchParams;
// TODO: API can't handle camelCase request parameter yet.
const searchOrQuery = origin
? sdk.listings.search({ ...searchParams, page, per_page: perPage })
: sdk.listings.query({ include, page, per_page: perPage });
return searchOrQuery
.then(response => {
dispatch(addMarketplaceEntities(response));
dispatch(searchMapListingsSuccess(response));
return response;
})
.catch(e => {
dispatch(searchMapListingsError(e));
throw e;
});
};

View file

@ -1,145 +1,194 @@
import React, { PropTypes } from 'react';
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 { unionWith } from 'lodash';
import config from '../../config';
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 { PageLayout, SearchResultsPanel, Topbar } from '../../components';
import { searchListings } from './SearchPage.duck';
import { SearchMap, ModalInMobile, PageLayout, SearchResultsPanel, Topbar } from '../../components';
import { searchListings, searchMapListings } from './SearchPage.duck';
import css from './SearchPage.css';
// TODO Pagination page size might need to be dynamic on responsive page layouts
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 pickSearchParamsOnly = params => {
const { address, origin, bounds } = params || {};
return { address, origin, bounds };
};
export const SearchPageComponent = props => {
const {
authInfoError,
authInProgress,
currentUser,
currentUserHasListings,
history,
isAuthenticated,
listings,
location,
logoutError,
notificationCount,
onLogout,
onManageDisableScrolling,
pagination,
scrollingDisabled,
searchInProgress,
searchListingsError,
searchParams,
tab,
} = props;
export class SearchPageComponent extends Component {
componentDidMount() {
const { location, 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 };
// eslint-disable-next-line no-unused-vars
const { page, ...searchInURL } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
// Search more listings
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`);
});
}
// Page transition might initially use values from previous search
const searchParamsInURL = stringify(pickSearchParamsOnly(searchInURL));
const searchParamsInProps = stringify(pickSearchParamsOnly(searchParams));
const searchParamsMatch = searchParamsInURL === searchParamsInProps;
render() {
const {
authInfoError,
authInProgress,
currentUser,
currentUserHasListings,
history,
isAuthenticated,
listings,
location,
logoutError,
mapListings,
notificationCount,
onLogout,
onManageDisableScrolling,
pagination,
scrollingDisabled,
searchInProgress,
searchListingsError,
searchParams,
tab,
} = this.props;
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
const totalItems = searchParamsMatch && hasPaginationInfo ? pagination.totalItems : 0;
const listingsAreLoaded = !searchInProgress && searchParamsMatch && hasPaginationInfo;
// eslint-disable-next-line no-unused-vars
const { page, ...searchInURL } = parse(location.search, {
latlng: ['origin'],
latlngBounds: ['bounds'],
});
const searchError = (
<h2 className={css.error}>
<FormattedMessage id="SearchPage.searchError" />
</h2>
);
// Page transition might initially use values from previous search
const searchParamsInURL = stringify(pickSearchParamsOnly(searchInURL));
const searchParamsInProps = stringify(pickSearchParamsOnly(searchParams));
const searchParamsMatch = searchParamsInURL === searchParamsInProps;
const resultsFoundNoAddress = (
<h2>
<FormattedMessage id="SearchPage.foundResults" values={{ count: totalItems }} />
</h2>
);
const address = searchInURL && searchInURL.address
? <span className={css.searchString}>{searchInURL.address.split(', ')[0]}</span>
: null;
const resultsFoundWithAddress = (
<h2>
<FormattedMessage
id="SearchPage.foundResultsWithAddress"
values={{ count: totalItems, address }}
/>
</h2>
);
const resultsFound = address ? resultsFoundWithAddress : resultsFoundNoAddress;
const { address, bounds, origin } = searchInURL || {};
const noResults = (
<h2>
<FormattedMessage id="SearchPage.noResults" />
</h2>
);
const hasPaginationInfo = !!pagination && pagination.totalItems != null;
const totalItems = searchParamsMatch && hasPaginationInfo ? pagination.totalItems : 0;
const listingsAreLoaded = !searchInProgress && searchParamsMatch && hasPaginationInfo;
const loadingResults = (
<h2>
<FormattedMessage id="SearchPage.loadingResults" />
</h2>
);
const searchError = (
<h2 className={css.error}>
<FormattedMessage id="SearchPage.searchError" />
</h2>
);
const searchParamsForPagination = parse(location.search);
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 ? resultsFoundWithAddress : resultsFoundNoAddress;
return (
<PageLayout
authInfoError={authInfoError}
logoutError={logoutError}
title={`Search page: ${tab}`}
scrollingDisabled={scrollingDisabled}
>
<Topbar
isAuthenticated={isAuthenticated}
authInProgress={authInProgress}
currentUser={currentUser}
currentUserHasListings={currentUserHasListings}
notificationCount={notificationCount}
history={history}
location={location}
onLogout={onLogout}
onManageDisableScrolling={onManageDisableScrolling}
/>
<div className={css.searchResultSummary}>
{searchListingsError ? searchError : null}
{listingsAreLoaded && totalItems > 0 ? resultsFound : null}
{listingsAreLoaded && totalItems === 0 ? noResults : null}
{searchInProgress ? loadingResults : null}
</div>
<div className={css.container}>
<div className={css.listings}>
<SearchResultsPanel
className={css.searchListingsPanel}
currencyConfig={config.currencyConfig}
listings={listingsAreLoaded ? listings : []}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
/>
const noResults = (
<h2>
<FormattedMessage id="SearchPage.noResults" />
</h2>
);
const loadingResults = (
<h2>
<FormattedMessage id="SearchPage.loadingResults" />
</h2>
);
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={css.listings}>
<SearchResultsPanel
className={css.searchListingsPanel}
currencyConfig={config.currencyConfig}
listings={listingsAreLoaded ? listings : []}
pagination={listingsAreLoaded ? pagination : null}
search={searchParamsForPagination}
/>
</div>
</div>
<ModalInMobile
className={css.mapPanel}
id="SearchPage.map"
isModalOpenOnMobile={false}
onManageDisableScrolling={onManageDisableScrolling}
>
<div className={css.map}>
<SearchMap bounds={bounds} center={origin} listings={mapListings || []} />
</div>
</ModalInMobile>
</div>
</div>
</PageLayout>
);
};
</PageLayout>
);
}
}
SearchPageComponent.defaultProps = {
authInfoError: null,
currentUser: null,
listings: [],
logoutError: null,
mapListings: [],
notificationCount: 0,
pagination: null,
searchListingsError: null,
@ -156,10 +205,12 @@ SearchPageComponent.propTypes = {
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,
@ -183,6 +234,7 @@ const mapStateToProps = state => {
searchInProgress,
searchListingsError,
searchParams,
searchMapListingIds,
} = state.SearchPage;
const { authInfoError, isAuthenticated, logoutError } = state.Auth;
const {
@ -190,10 +242,17 @@ const mapStateToProps = state => {
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: getListingsById(state, currentPageResultIds),
listings: pageListings,
mapListings,
pagination,
searchInProgress,
searchListingsError,
@ -211,6 +270,7 @@ 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), withRouter)(

View file

@ -37,6 +37,7 @@ describe('SearchPageComponent', () => {
isAuthenticated: false,
onLogout: noop,
onManageDisableScrolling: noop,
onSearchMapListings: noop,
};
const tree = renderShallow(<SearchPageComponent {...props} />);
expect(tree).toMatchSnapshot();

View file

@ -22,44 +22,68 @@ exports[`SearchPageComponent matches snapshot 1`] = `
notificationCount={0}
onLogout={[Function]}
onManageDisableScrolling={[Function]} />
<div>
<h2>
<FormattedMessage
id="SearchPage.foundResults"
values={
Object {
"count": 22,
}
} />
</h2>
</div>
<div>
<div>
<SearchResultsPanel
className={null}
currencyConfig={
Object {
"currency": "USD",
"currencyDisplay": "symbol",
"maximumFractionDigits": 2,
"minimumFractionDigits": 2,
"style": "currency",
"subUnitDivisor": 100,
"useGrouping": true,
<div>
<h2>
<FormattedMessage
id="SearchPage.foundResults"
values={
Object {
"count": 22,
}
} />
</h2>
</div>
<div>
<SearchResultsPanel
className={null}
currencyConfig={
Object {
"currency": "USD",
"currencyDisplay": "symbol",
"maximumFractionDigits": 2,
"minimumFractionDigits": 2,
"style": "currency",
"subUnitDivisor": 100,
"useGrouping": true,
}
}
}
listings={Array []}
pagination={
Object {
"page": 1,
"perPage": 12,
"totalItems": 22,
"totalPages": 2,
listings={Array []}
pagination={
Object {
"page": 1,
"perPage": 12,
"totalItems": 22,
"totalPages": 2,
}
}
}
rootClassName={null}
search={Object {}} />
rootClassName={null}
search={Object {}} />
</div>
</div>
<ModalInMobile
className=""
id="SearchPage.map"
isModalOpenOnMobile={false}
onClose={null}
onManageDisableScrolling={[Function]}
showAsModalMaxWidth={0}>
<div>
<SearchMap
bounds={null}
center={
LatLng {
"lat": 0,
"lng": 0,
}
}
className=""
listings={Array []}
rootClassName={null}
zoom={11} />
</div>
</ModalInMobile>
</div>
</withRouter(PageLayout)>
`;

View file

@ -47,7 +47,12 @@ const TopbarSearchFormComponent = props => {
const { func, string, bool } = PropTypes;
TopbarSearchFormComponent.defaultProps = { rootClassName: null, className: null, desktopInputRoot: null, isMobile: false };
TopbarSearchFormComponent.defaultProps = {
rootClassName: null,
className: null,
desktopInputRoot: null,
isMobile: false,
};
TopbarSearchFormComponent.propTypes = {
...formPropTypes,

View file

@ -112,6 +112,7 @@
"LoginForm.passwordLabel": "Password",
"LoginForm.passwordPlaceholder": "Enter your password…",
"LoginForm.passwordRequired": "This field is required",
"MapPriceMarker.unsupportedPrice": "({currency})",
"Modal.close": "CLOSE",
"Modal.closeModal": "Close modal",
"OrderDetailsPanel.bookingBreakdownTitle": "Booking breakdown",