mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-26 06:47:17 +10:00
commit
5db24d7770
42 changed files with 1827 additions and 46 deletions
12
CHANGELOG.md
12
CHANGELOG.md
|
|
@ -12,7 +12,17 @@ way to update this template, but currently, we follow a pattern:
|
|||
|
||||
---
|
||||
|
||||
## v.2.1.1 2018-10-23
|
||||
## v2.2.0 2018-11-XX
|
||||
|
||||
* [add] SearchPage: adds PriceFilter (and RangeSlider, FieldRangeSlider, PriceFilterForm).
|
||||
|
||||
**Note:** You must define min and max for the filter in `src/marketplace-custom-config.js`.
|
||||
Current maximum value for the range is set to 1000 (USD/EUR/currency units). In addition, this
|
||||
fixes or removes component examples that don't work in StyleguidePage.
|
||||
|
||||
[#944](https://github.com/sharetribe/flex-template-web/pull/944)
|
||||
|
||||
## v2.1.1 2018-10-23
|
||||
|
||||
* [add] Added initial documentation about routing and loading data.
|
||||
[#941](https://github.com/sharetribe/flex-template-web/pull/941)
|
||||
|
|
|
|||
|
|
@ -5,12 +5,17 @@ filters rely on listing's indexed data.
|
|||
|
||||
## Filter types
|
||||
|
||||
The Flex template for web has two different filter types: _select single_ and _select multiple_. The
|
||||
_select single_ one can be used to filter out search result with only one value per search
|
||||
parameter. The _select multiple_ filters on the other hand can take multiple values for a single
|
||||
search parameter.
|
||||
The Flex template for web has three different filter types: _price filter_, _select single_ and
|
||||
_select multiple_. The _price filter_ is for the specific case of filtering listings with a price
|
||||
range.
|
||||
|
||||
These two filter types are implemented with four different components, a standard and a plain one:
|
||||
> NOTE: price filter should be configured from `src/marketplace-custom-config.js`. Current maximum
|
||||
> value for the range is set to 1000 (USD/EUR).
|
||||
|
||||
Other two filter types can be used with extended data. The _select single_ one can be used to filter
|
||||
out search result with only one value per search parameter. The _select multiple_ filters on the
|
||||
other hand can take multiple values for a single search parameter. These two filter types for
|
||||
extended data are implemented with four different components, a standard and a plain one:
|
||||
|
||||
* Select single filter: `SelectSingleFilter` and `SelectSingleFilterPlain`
|
||||
* Select multiple filter: `SelectMultipleFilter` and `SelectMultipleFilterPlain`
|
||||
|
|
|
|||
73
src/components/FieldRangeSlider/FieldRangeSlider.example.js
Normal file
73
src/components/FieldRangeSlider/FieldRangeSlider.example.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/* eslint-disable no-console */
|
||||
import React from 'react';
|
||||
import { Form as FinalForm, FormSpy } from 'react-final-form';
|
||||
import { Button } from '../../components';
|
||||
import FieldRangeSlider from './FieldRangeSlider';
|
||||
|
||||
const formName = 'Styleguide.FieldRangeSlider.Form';
|
||||
|
||||
const FormComponent = props => (
|
||||
<FinalForm
|
||||
{...props}
|
||||
formId={formName}
|
||||
render={fieldRenderProps => {
|
||||
const {
|
||||
formId,
|
||||
handleSubmit,
|
||||
onChange,
|
||||
invalid,
|
||||
pristine,
|
||||
submitting,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
handles,
|
||||
} = fieldRenderProps;
|
||||
const submitDisabled = invalid || pristine || submitting;
|
||||
|
||||
return (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}}
|
||||
>
|
||||
<FormSpy onChange={onChange} />
|
||||
|
||||
<FieldRangeSlider
|
||||
id={`${formId}.range`}
|
||||
name="range"
|
||||
label="Select range"
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
handles={handles}
|
||||
/>
|
||||
|
||||
<Button style={{ marginTop: 24 }} type="submit" disabled={submitDisabled}>
|
||||
Submit
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export const FieldRangeSliderForm = {
|
||||
component: FormComponent,
|
||||
props: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
handles: [333, 666],
|
||||
onChange: formState => {
|
||||
if (formState.dirty) {
|
||||
console.log('form values changed to:', formState.values);
|
||||
}
|
||||
},
|
||||
onSubmit: values => {
|
||||
console.log('submit values:', values);
|
||||
},
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
32
src/components/FieldRangeSlider/FieldRangeSlider.js
Normal file
32
src/components/FieldRangeSlider/FieldRangeSlider.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import React from 'react';
|
||||
import { Field } from 'react-final-form';
|
||||
import classNames from 'classnames';
|
||||
import { RangeSlider } from '../../components';
|
||||
|
||||
const RangeSliderInput = props => {
|
||||
const { input, handles, ...rest } = props;
|
||||
const { value, ...inputProps } = input;
|
||||
|
||||
const currentHandles = Array.isArray(value) ? value : handles;
|
||||
return <RangeSlider {...inputProps} {...rest} handles={currentHandles} />;
|
||||
};
|
||||
|
||||
const FieldRangeSlider = props => {
|
||||
const { rootClassName, className, id, label, ...rest } = props;
|
||||
|
||||
if (label && !id) {
|
||||
throw new Error('id required when a label is given');
|
||||
}
|
||||
|
||||
const inputProps = { id, ...rest };
|
||||
const classes = classNames(rootClassName, className);
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
{label ? <label htmlFor={id}>{label}</label> : null}
|
||||
<Field component={RangeSliderInput} {...inputProps} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldRangeSlider;
|
||||
3
src/components/Footer/Footer.example.css
Normal file
3
src/components/Footer/Footer.example.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.example {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import Footer from './Footer';
|
||||
import css from './Footer.example.css';
|
||||
|
||||
export const Default = {
|
||||
component: Footer,
|
||||
props: {},
|
||||
props: { className: css.example },
|
||||
};
|
||||
|
|
|
|||
12
src/components/ImageCarousel/ImageCarousel.example.css
Normal file
12
src/components/ImageCarousel/ImageCarousel.example.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
|
||||
@media (--viewportMedium) {
|
||||
padding: 100px 10vw;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { types as sdkTypes } from '../../util/sdkLoader';
|
||||
import ImageCarousel from './ImageCarousel';
|
||||
import css from './ImageCarousel.example.css';
|
||||
|
||||
const { UUID } = sdkTypes;
|
||||
|
||||
|
|
@ -122,15 +123,15 @@ const ImageCarouselWrapper = props => {
|
|||
|
||||
export const NoImages = {
|
||||
component: ImageCarouselWrapper,
|
||||
props: { images: [] },
|
||||
props: { images: [], rootClassName: css.root },
|
||||
};
|
||||
|
||||
export const SingleImage = {
|
||||
component: ImageCarouselWrapper,
|
||||
props: { images: [imageSquare] },
|
||||
props: { images: [imageSquare], rootClassName: css.root },
|
||||
};
|
||||
|
||||
export const MultipleImages = {
|
||||
component: ImageCarouselWrapper,
|
||||
props: { images: [imageLandscape, imagePortrait, imageSquare] },
|
||||
props: { images: [imageLandscape, imagePortrait, imageSquare], rootClassName: css.root },
|
||||
};
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const MenuBasic = {
|
|||
|
||||
const MenuOnRight = () => {
|
||||
return (
|
||||
<div style={{ width: '50px', marginLeft: 'auto', marginRight: '36px' }}>
|
||||
<div style={{ width: '68px', marginLeft: 'auto', marginRight: '36x' }}>
|
||||
<MenuWrapper />
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
77
src/components/PriceFilter/PriceFilter.example.js
Normal file
77
src/components/PriceFilter/PriceFilter.example.js
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import React from 'react';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { stringify, parse } from '../../util/urlHelpers';
|
||||
|
||||
import PriceFilter from './PriceFilter';
|
||||
|
||||
const URL_PARAM = 'pub_price';
|
||||
const RADIX = 10;
|
||||
|
||||
// Helper for submitting example
|
||||
const handleSubmit = (urlParam, values, history) => {
|
||||
const { minPrice, maxPrice } = values || {};
|
||||
const queryParams =
|
||||
minPrice != null && maxPrice != null
|
||||
? `?${stringify({ [urlParam]: [minPrice, maxPrice].join(',') })}`
|
||||
: '';
|
||||
history.push(`${window.location.pathname}${queryParams}`);
|
||||
};
|
||||
|
||||
const PriceFilterWrapper = withRouter(props => {
|
||||
const { history, location } = props;
|
||||
|
||||
const params = parse(location.search);
|
||||
const price = params[URL_PARAM];
|
||||
const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : [];
|
||||
const initialValues = !!price
|
||||
? {
|
||||
minPrice: valuesFromParams[0],
|
||||
maxPrice: valuesFromParams[1],
|
||||
}
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PriceFilter
|
||||
{...props}
|
||||
initialValues={initialValues}
|
||||
onSubmit={(urlParam, values) => {
|
||||
console.log('Submit PriceFilterForm with (unformatted) values:', values);
|
||||
handleSubmit(urlParam, values, history);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const PriceFilterPopup = {
|
||||
component: PriceFilterWrapper,
|
||||
props: {
|
||||
id: 'PriceFilterPopupExample',
|
||||
urlParam: URL_PARAM,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
liveEdit: false,
|
||||
showAsPopup: true,
|
||||
contentPlacementOffset: -14,
|
||||
// initialValues: handled inside wrapper
|
||||
// onSubmit: handled inside wrapper
|
||||
},
|
||||
group: 'misc',
|
||||
};
|
||||
|
||||
export const PriceFilterPlain = {
|
||||
component: PriceFilterWrapper,
|
||||
props: {
|
||||
id: 'PriceFilterPlainExample',
|
||||
urlParam: URL_PARAM,
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
liveEdit: true,
|
||||
showAsPopup: false,
|
||||
contentPlacementOffset: -14,
|
||||
// initialValues: handled inside wrapper
|
||||
// onSubmit: handled inside wrapper
|
||||
},
|
||||
group: 'misc',
|
||||
};
|
||||
19
src/components/PriceFilter/PriceFilter.js
Normal file
19
src/components/PriceFilter/PriceFilter.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import React from 'react';
|
||||
import { bool } from 'prop-types';
|
||||
import PriceFilterPlain from './PriceFilterPlain';
|
||||
import PriceFilterPopup from './PriceFilterPopup';
|
||||
|
||||
const PriceFilter = props => {
|
||||
const { showAsPopup, ...rest } = props;
|
||||
return showAsPopup ? <PriceFilterPopup {...rest} /> : <PriceFilterPlain {...rest} />;
|
||||
};
|
||||
|
||||
PriceFilter.defaultProps = {
|
||||
showAsPopup: false,
|
||||
};
|
||||
|
||||
PriceFilter.propTypes = {
|
||||
showAsPopup: bool,
|
||||
};
|
||||
|
||||
export default PriceFilter;
|
||||
57
src/components/PriceFilter/PriceFilterPlain.css
Normal file
57
src/components/PriceFilter/PriceFilterPlain.css
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
padding-top: 24px;
|
||||
padding-bottom: 17px;
|
||||
border-bottom: 1px solid var(--matterColorNegative);
|
||||
}
|
||||
|
||||
.filterLabel,
|
||||
.filterLabelSelected {
|
||||
@apply --marketplaceH3FontStyles;
|
||||
|
||||
/* Baseline adjustment for label text */
|
||||
margin-top: 0;
|
||||
margin-bottom: 12px;
|
||||
padding: 4px 0 2px 0;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
color: var(--matterColorDark);
|
||||
}
|
||||
|
||||
.filterLabelSelected {
|
||||
color: var(--marketplaceColor);
|
||||
}
|
||||
|
||||
.labelButton {
|
||||
/* Override button styles */
|
||||
outline: none;
|
||||
text-align: left;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
@apply --marketplaceH5FontStyles;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
/* Layout */
|
||||
display: inline;
|
||||
float: right;
|
||||
margin-top: 6px;
|
||||
padding: 0;
|
||||
|
||||
/* Override button styles */
|
||||
outline: none;
|
||||
text-align: left;
|
||||
border: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--matterColor);
|
||||
}
|
||||
}
|
||||
124
src/components/PriceFilter/PriceFilterPlain.js
Normal file
124
src/components/PriceFilter/PriceFilterPlain.js
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import React, { Component } from 'react';
|
||||
import { func, number, shape, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { formatCurrencyMajorUnit } from '../../util/currency';
|
||||
import config from '../../config';
|
||||
|
||||
import { PriceFilterForm } from '../../forms';
|
||||
|
||||
import css from './PriceFilterPlain.css';
|
||||
|
||||
class PriceFilterPlainComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { isOpen: true };
|
||||
|
||||
this.handleChange = this.handleChange.bind(this);
|
||||
this.handleClear = this.handleClear.bind(this);
|
||||
this.toggleIsOpen = this.toggleIsOpen.bind(this);
|
||||
}
|
||||
|
||||
handleChange(values) {
|
||||
const { onSubmit, urlParam } = this.props;
|
||||
onSubmit(urlParam, values);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
const { onSubmit, urlParam } = this.props;
|
||||
onSubmit(urlParam, null);
|
||||
}
|
||||
|
||||
toggleIsOpen() {
|
||||
this.setState(prevState => ({ isOpen: !prevState.isOpen }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
id,
|
||||
initialValues,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
intl,
|
||||
currencyConfig,
|
||||
} = this.props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const { minPrice, maxPrice } = initialValues || {};
|
||||
|
||||
const hasValue = value => value != null;
|
||||
const hasInitialValues = initialValues && hasValue(minPrice) && hasValue(maxPrice);
|
||||
|
||||
const labelClass = hasInitialValues ? css.filterLabelSelected : css.filterLabel;
|
||||
const labelText = hasInitialValues
|
||||
? intl.formatMessage(
|
||||
{ id: 'PriceFilter.labelSelectedPlain' },
|
||||
{
|
||||
minPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, minPrice),
|
||||
maxPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, maxPrice),
|
||||
}
|
||||
)
|
||||
: intl.formatMessage({ id: 'PriceFilter.label' });
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={labelClass}>
|
||||
<button type="button" className={css.labelButton} onClick={this.toggleIsOpen}>
|
||||
<span className={labelClass}>{labelText}</span>
|
||||
</button>
|
||||
<button type="button" className={css.clearButton} onClick={this.handleClear}>
|
||||
<FormattedMessage id={'PriceFilter.clear'} />
|
||||
</button>
|
||||
</div>
|
||||
<PriceFilterForm
|
||||
id={id}
|
||||
initialValues={hasInitialValues ? initialValues : { minPrice: min, maxPrice: max }}
|
||||
onChange={this.handleChange}
|
||||
intl={intl}
|
||||
contentRef={node => {
|
||||
this.filterContent = node;
|
||||
}}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
liveEdit
|
||||
isOpen={this.state.isOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PriceFilterPlainComponent.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
initialValues: null,
|
||||
step: number,
|
||||
currencyConfig: config.currencyConfig,
|
||||
};
|
||||
|
||||
PriceFilterPlainComponent.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
id: string.isRequired,
|
||||
urlParam: string.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
initialValues: shape({
|
||||
minPrice: number.isRequired,
|
||||
maxPrice: number.isRequired,
|
||||
}),
|
||||
min: number.isRequired,
|
||||
max: number.isRequired,
|
||||
step: number,
|
||||
currencyConfig: propTypes.currencyConfig,
|
||||
|
||||
// form injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const PriceFilterPlain = injectIntl(PriceFilterPlainComponent);
|
||||
|
||||
export default PriceFilterPlain;
|
||||
43
src/components/PriceFilter/PriceFilterPopup.css
Normal file
43
src/components/PriceFilter/PriceFilterPopup.css
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply --marketplaceButtonStylesSecondary;
|
||||
@apply --marketplaceSearchFilterLabelFontStyles;
|
||||
|
||||
padding: 9px 16px 10px 16px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
border-radius: 4px;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
background-color: var(--matterColorLight);
|
||||
border-color: transparent;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--boxShadowFilterButton);
|
||||
}
|
||||
}
|
||||
|
||||
.labelSelected {
|
||||
@apply --marketplaceButtonStyles;
|
||||
@apply --marketplaceSearchFilterLabelFontStyles;
|
||||
font-weight: var(--fontWeightSemiBold);
|
||||
|
||||
padding: 9px 16px 10px 16px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--marketplaceColor);
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
border: 1px solid var(--marketplaceColorDark);
|
||||
}
|
||||
}
|
||||
190
src/components/PriceFilter/PriceFilterPopup.js
Normal file
190
src/components/PriceFilter/PriceFilterPopup.js
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import React, { Component } from 'react';
|
||||
import { func, number, shape, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { injectIntl, intlShape } from 'react-intl';
|
||||
import { propTypes } from '../../util/types';
|
||||
import { formatCurrencyMajorUnit } from '../../util/currency';
|
||||
import config from '../../config';
|
||||
|
||||
import { PriceFilterForm } from '../../forms';
|
||||
import css from './PriceFilterPopup.css';
|
||||
|
||||
const KEY_CODE_ESCAPE = 27;
|
||||
|
||||
class PriceFilterPopup extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { isOpen: false };
|
||||
this.filter = null;
|
||||
this.filterContent = null;
|
||||
|
||||
this.handleSubmit = this.handleSubmit.bind(this);
|
||||
this.handleClear = this.handleClear.bind(this);
|
||||
this.handleCancel = this.handleCancel.bind(this);
|
||||
this.handleBlur = this.handleBlur.bind(this);
|
||||
this.handleKeyDown = this.handleKeyDown.bind(this);
|
||||
this.toggleOpen = this.toggleOpen.bind(this);
|
||||
this.positionStyleForContent = this.positionStyleForContent.bind(this);
|
||||
}
|
||||
|
||||
handleSubmit(values) {
|
||||
const { onSubmit, urlParam } = this.props;
|
||||
this.setState({ isOpen: false });
|
||||
onSubmit(urlParam, values);
|
||||
}
|
||||
|
||||
handleClear() {
|
||||
const { onSubmit, urlParam } = this.props;
|
||||
this.setState({ isOpen: false });
|
||||
onSubmit(urlParam, null);
|
||||
}
|
||||
|
||||
handleCancel() {
|
||||
const { onSubmit, initialValues, urlParam } = this.props;
|
||||
this.setState({ isOpen: false });
|
||||
onSubmit(urlParam, initialValues);
|
||||
}
|
||||
|
||||
handleBlur(event) {
|
||||
// FocusEvent is fired faster than the link elements native click handler
|
||||
// gets its own event. Therefore, we need to check the origin of this FocusEvent.
|
||||
if (!this.filter.contains(event.relatedTarget)) {
|
||||
this.setState({ isOpen: false });
|
||||
}
|
||||
}
|
||||
|
||||
handleKeyDown(e) {
|
||||
// Gather all escape presses to close menu
|
||||
if (e.keyCode === KEY_CODE_ESCAPE) {
|
||||
this.toggleOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleOpen(enforcedState) {
|
||||
if (enforcedState) {
|
||||
this.setState({ isOpen: enforcedState });
|
||||
} else {
|
||||
this.setState(prevState => ({ isOpen: !prevState.isOpen }));
|
||||
}
|
||||
}
|
||||
|
||||
positionStyleForContent() {
|
||||
if (this.filter && this.filterContent) {
|
||||
// Render the filter content to the right from the menu
|
||||
// unless there's no space in which case it is rendered
|
||||
// to the left
|
||||
const distanceToRight = window.innerWidth - this.filter.getBoundingClientRect().right;
|
||||
const labelWidth = this.filter.offsetWidth;
|
||||
const contentWidth = this.filterContent.offsetWidth;
|
||||
const contentWidthBiggerThanLabel = contentWidth - labelWidth;
|
||||
const renderToRight = distanceToRight > contentWidthBiggerThanLabel;
|
||||
const contentPlacementOffset = this.props.contentPlacementOffset;
|
||||
|
||||
const offset = renderToRight
|
||||
? { left: contentPlacementOffset }
|
||||
: { right: contentPlacementOffset };
|
||||
// set a min-width if the content is narrower than the label
|
||||
const minWidth = contentWidth < labelWidth ? { minWidth: labelWidth } : null;
|
||||
|
||||
return { ...offset, ...minWidth };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootClassName,
|
||||
className,
|
||||
id,
|
||||
initialValues,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
intl,
|
||||
currencyConfig,
|
||||
} = this.props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
const { minPrice, maxPrice } = initialValues || {};
|
||||
|
||||
const hasValue = value => value != null;
|
||||
const hasInitialValues = initialValues && hasValue(minPrice) && hasValue(maxPrice);
|
||||
|
||||
const label = hasInitialValues
|
||||
? intl.formatMessage(
|
||||
{ id: 'PriceFilter.labelSelectedButton' },
|
||||
{
|
||||
minPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, minPrice),
|
||||
maxPrice: formatCurrencyMajorUnit(intl, currencyConfig.currency, maxPrice),
|
||||
}
|
||||
)
|
||||
: intl.formatMessage({ id: 'PriceFilter.label' });
|
||||
|
||||
const labelStyles = hasInitialValues ? css.labelSelected : css.label;
|
||||
const contentStyle = this.positionStyleForContent();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
onBlur={this.handleBlur}
|
||||
onKeyDown={this.handleKeyDown}
|
||||
ref={node => {
|
||||
this.filter = node;
|
||||
}}
|
||||
>
|
||||
<button className={labelStyles} onClick={() => this.toggleOpen()}>
|
||||
{label}
|
||||
</button>
|
||||
<PriceFilterForm
|
||||
id={id}
|
||||
initialValues={hasInitialValues ? initialValues : { minPrice: min, maxPrice: max }}
|
||||
onClear={this.handleClear}
|
||||
onCancel={this.handleCancel}
|
||||
onSubmit={this.handleSubmit}
|
||||
intl={intl}
|
||||
contentRef={node => {
|
||||
this.filterContent = node;
|
||||
}}
|
||||
style={contentStyle}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
showAsPopup
|
||||
isOpen={this.state.isOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PriceFilterPopup.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
initialValues: null,
|
||||
contentPlacementOffset: 0,
|
||||
liveEdit: false,
|
||||
step: number,
|
||||
currencyConfig: config.currencyConfig,
|
||||
};
|
||||
|
||||
PriceFilterPopup.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
id: string.isRequired,
|
||||
urlParam: string.isRequired,
|
||||
onSubmit: func.isRequired,
|
||||
initialValues: shape({
|
||||
minPrice: number.isRequired,
|
||||
maxPrice: number.isRequired,
|
||||
}),
|
||||
contentPlacementOffset: number,
|
||||
min: number.isRequired,
|
||||
max: number.isRequired,
|
||||
step: number,
|
||||
currencyConfig: propTypes.currencyConfig,
|
||||
|
||||
// form injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
export default injectIntl(PriceFilterPopup);
|
||||
40
src/components/RangeSlider/Handle.css
Normal file
40
src/components/RangeSlider/Handle.css
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.rootTouchBuffer {
|
||||
/* Position */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -22px;
|
||||
margin-left: -22px;
|
||||
|
||||
/* Layout */
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.visibleHandle {
|
||||
/* Position */
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
margin-left: 12px;
|
||||
|
||||
/* Layout */
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--marketplaceColor);
|
||||
border-radius: 50%;
|
||||
background-color: var(--matterColorLight);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
.dragged {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
141
src/components/RangeSlider/Handle.js
Normal file
141
src/components/RangeSlider/Handle.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import React, { Component } from 'react';
|
||||
import { func, number, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './Handle.css';
|
||||
|
||||
class Handle extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = { dragging: false, relativePos: null };
|
||||
|
||||
this.handleRef = React.createRef();
|
||||
this._isMounted = false;
|
||||
|
||||
this.onMouseDown = this.onMouseDown.bind(this);
|
||||
this.onMouseMove = this.onMouseMove.bind(this);
|
||||
this.onMouseUp = this.onMouseUp.bind(this);
|
||||
this.onTouchStart = this.onTouchStart.bind(this);
|
||||
this.onTouchMove = this.onTouchMove.bind(this);
|
||||
this.onTouchEnd = this.onTouchEnd.bind(this);
|
||||
|
||||
this.onStart = this.onStart.bind(this);
|
||||
this.onMove = this.onMove.bind(this);
|
||||
this.onEnd = this.onEnd.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this._isMounted = true;
|
||||
this.onMouseMoveListener = window.addEventListener('mousemove', this.onMouseMove, false);
|
||||
this.onMouseUpListener = window.addEventListener('mouseup', this.onMouseUp, false);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this._isMounted = false;
|
||||
window.removeEventListener('mousemove', this.onMouseMoveListener, false);
|
||||
window.removeEventListener('mouseup', this.onMouseUpListener, false);
|
||||
}
|
||||
|
||||
onMouseDown(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
this.onStart(e.pageX);
|
||||
}
|
||||
onMouseMove(e) {
|
||||
if (!this.state.dragging) return;
|
||||
|
||||
this.onMove(e.pageX);
|
||||
}
|
||||
onMouseUp(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
this.onEnd();
|
||||
}
|
||||
|
||||
onTouchStart(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const touchpageX = e.touches[0].pageX;
|
||||
this.onStart(touchpageX);
|
||||
}
|
||||
onTouchMove(e) {
|
||||
if (!this.state.dragging) return;
|
||||
|
||||
this.onMove(e.touches[0].pageX);
|
||||
}
|
||||
onTouchEnd(e) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
this.onEnd();
|
||||
}
|
||||
|
||||
onStart(pagePosition) {
|
||||
const { offsetLeft, offsetWidth } = this.handleRef.current;
|
||||
this.setState({ dragging: true, relativePos: pagePosition - offsetLeft - offsetWidth / 2 });
|
||||
this.props.changeActive();
|
||||
}
|
||||
onMove(pagePosition) {
|
||||
const { min, max, positionToValue } = this.props;
|
||||
const position = pagePosition - this.state.relativePos;
|
||||
const currentValue = positionToValue(position);
|
||||
|
||||
const value = currentValue < min ? min : currentValue > max ? max : currentValue;
|
||||
|
||||
this.props.onChange(value);
|
||||
}
|
||||
onEnd() {
|
||||
// Ensuring that setState doesn't get called.
|
||||
// This a strange behaviour since window.removeEventListener is called in componentWillUnmount
|
||||
if (this._isMounted) {
|
||||
this.setState({ dragging: false });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { rootClassName, className, value, valueToPosition } = this.props;
|
||||
const position = valueToPosition(value);
|
||||
const classes = classNames(rootClassName || css.rootTouchBuffer, className);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
ref={this.handleRef}
|
||||
style={{ left: `${position}px` }}
|
||||
onMouseDown={this.onMouseDown}
|
||||
onMouseMove={this.onMouseMove}
|
||||
onMouseUp={this.onMouseUp}
|
||||
onTouchStart={this.onTouchStart}
|
||||
onTouchMove={this.onTouchMove}
|
||||
onTouchEnd={this.onTouchEnd}
|
||||
role="button"
|
||||
>
|
||||
<div
|
||||
className={classNames(css.visibleHandle, {
|
||||
[css.dragged]: this.state.dragging,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Handle.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
};
|
||||
|
||||
Handle.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
min: number.isRequired,
|
||||
max: number.isRequired,
|
||||
valueToPosition: func.isRequired,
|
||||
positionToValue: func.isRequired,
|
||||
};
|
||||
|
||||
export default Handle;
|
||||
12
src/components/RangeSlider/RangeSlider.css
Normal file
12
src/components/RangeSlider/RangeSlider.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
margin: 0 10px;
|
||||
width: calc(100% - 20px);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.activeHandle {
|
||||
z-index: 1;
|
||||
}
|
||||
54
src/components/RangeSlider/RangeSlider.example.js
Normal file
54
src/components/RangeSlider/RangeSlider.example.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import React, { Component } from 'react';
|
||||
import RangeSlider from './RangeSlider';
|
||||
|
||||
class RangeSliderWrapper extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { handles: props.handles };
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<RangeSlider
|
||||
{...this.props}
|
||||
handles={this.state.handles}
|
||||
onChange={v => {
|
||||
this.setState({ handles: v });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const RangeSliderOneHandle = {
|
||||
component: RangeSliderWrapper,
|
||||
props: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
handles: [500],
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
|
||||
export const RangeSliderTwoHandles = {
|
||||
component: RangeSliderWrapper,
|
||||
props: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
handles: [333, 666],
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
|
||||
export const RangeSliderThreeHandles = {
|
||||
component: RangeSliderWrapper,
|
||||
props: {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
handles: [150, 490, 850],
|
||||
},
|
||||
group: 'custom inputs',
|
||||
};
|
||||
120
src/components/RangeSlider/RangeSlider.js
Normal file
120
src/components/RangeSlider/RangeSlider.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import React, { Component } from 'react';
|
||||
import { arrayOf, number, shape, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { withDimensions } from '../../util/contextHelpers';
|
||||
|
||||
import Handle from './Handle';
|
||||
import Track from './Track';
|
||||
import css from './RangeSlider.css';
|
||||
|
||||
class RangeSliderComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
const { min, max, handles } = props;
|
||||
handles.forEach((h, index) => {
|
||||
if (h < min || h > max || (index < handles.length - 1 && h > handles[index + 1])) {
|
||||
throw new Error(
|
||||
'RangeSlider error: handles need to be given in ascending order and they need to be within min and max values'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
this.state = { activeHandle: 0 };
|
||||
|
||||
this.toPosition = this.toPosition.bind(this);
|
||||
this.toValue = this.toValue.bind(this);
|
||||
this.changeActive = this.changeActive.bind(this);
|
||||
this.onChange = this.onChange.bind(this);
|
||||
}
|
||||
|
||||
toPosition(value) {
|
||||
const { dimensions, min, max } = this.props;
|
||||
const width = dimensions.width;
|
||||
const valueOffset = value - min;
|
||||
const scale = max - min;
|
||||
return Math.round(valueOffset / scale * width);
|
||||
}
|
||||
|
||||
toValue(position) {
|
||||
const { dimensions, min, max, step } = this.props;
|
||||
const width = dimensions.width;
|
||||
const scale = max - min;
|
||||
const value = Math.round(position / width * scale) + min;
|
||||
return Math.ceil(value / step) * step;
|
||||
}
|
||||
|
||||
changeActive(index) {
|
||||
this.setState({ activeHandle: index });
|
||||
}
|
||||
|
||||
onChange(position, handleIndex) {
|
||||
this.props.onChange(Object.assign([...this.props.handles], { [handleIndex]: position }));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { handles, min, max } = this.props;
|
||||
|
||||
return (
|
||||
<Track handles={handles} valueToPosition={this.toPosition}>
|
||||
{handles.map((h, index) => {
|
||||
const classes = classNames({ [css.activeHandle]: this.state.activeHandle === index });
|
||||
return (
|
||||
<Handle
|
||||
key={index}
|
||||
className={classes}
|
||||
value={h}
|
||||
min={index === 0 ? min : handles[index - 1]}
|
||||
max={index === handles.length - 1 ? max : handles[index + 1]}
|
||||
valueToPosition={this.toPosition}
|
||||
positionToValue={this.toValue}
|
||||
changeActive={() => this.changeActive(index)}
|
||||
onChange={value => this.onChange(value, index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Track>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
RangeSliderComponent.defaultProps = {
|
||||
min: 0,
|
||||
max: 10000000,
|
||||
step: 1,
|
||||
};
|
||||
|
||||
RangeSliderComponent.propTypes = {
|
||||
handles: arrayOf(number),
|
||||
min: number,
|
||||
max: number,
|
||||
step: number,
|
||||
dimensions: shape({
|
||||
height: number.isRequired,
|
||||
width: number.isRequired,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
const RangeSliderComponentWithDimensions = withDimensions(RangeSliderComponent);
|
||||
|
||||
const RangeSlider = props => {
|
||||
const { rootClassName, className, ...rest } = props;
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
return (
|
||||
<div className={classes}>
|
||||
<RangeSliderComponentWithDimensions {...rest} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
RangeSlider.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
};
|
||||
|
||||
RangeSlider.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
};
|
||||
|
||||
export default RangeSlider;
|
||||
33
src/components/RangeSlider/Track.css
Normal file
33
src/components/RangeSlider/Track.css
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.track {
|
||||
/* Position */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -4px;
|
||||
margin-left: -10px;
|
||||
|
||||
/* Layout */
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
padding: 0 10px;
|
||||
|
||||
box-sizing: content-box;
|
||||
background-color: var(--matterColorNegative);
|
||||
border-radius: 4px;
|
||||
box-shadow: inset 0 1px 3px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.range {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -4px;
|
||||
height: 8px;
|
||||
background-color: var(--marketplaceColor);
|
||||
}
|
||||
53
src/components/RangeSlider/Track.js
Normal file
53
src/components/RangeSlider/Track.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React, { Component } from 'react';
|
||||
import { array, node, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
|
||||
import css from './Track.css';
|
||||
|
||||
class Track extends Component {
|
||||
render() {
|
||||
const { rootClassName, className, children, handles, valueToPosition } = this.props;
|
||||
const positionFromIndex = index => valueToPosition(handles[index]);
|
||||
|
||||
const classes = classNames(rootClassName || css.root, className);
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={css.track} />
|
||||
|
||||
{handles.reduce((ranges, h, index) => {
|
||||
return index < handles.length - 1
|
||||
? [
|
||||
...ranges,
|
||||
<div
|
||||
key={`range_${index}-${index + 1}`}
|
||||
className={css.range}
|
||||
style={{
|
||||
left: `${valueToPosition(h)}px`,
|
||||
width: `${positionFromIndex(index + 1) - valueToPosition(h)}px`,
|
||||
}}
|
||||
/>,
|
||||
]
|
||||
: ranges;
|
||||
}, [])}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Track.defaultProps = {
|
||||
rootClassName: null,
|
||||
className: null,
|
||||
children: null,
|
||||
handles: [],
|
||||
};
|
||||
|
||||
Track.propTypes = {
|
||||
rootClassName: string,
|
||||
className: string,
|
||||
children: node,
|
||||
handles: array,
|
||||
};
|
||||
|
||||
export default Track;
|
||||
|
|
@ -6,7 +6,7 @@ import classNames from 'classnames';
|
|||
import { withRouter } from 'react-router-dom';
|
||||
import omit from 'lodash/omit';
|
||||
|
||||
import { SelectSingleFilter, SelectMultipleFilter } from '../../components';
|
||||
import { SelectSingleFilter, SelectMultipleFilter, PriceFilter } from '../../components';
|
||||
import routeConfiguration from '../../routeConfiguration';
|
||||
import { createResourceLocatorString } from '../../util/routes';
|
||||
import { propTypes } from '../../util/types';
|
||||
|
|
@ -14,6 +14,7 @@ import css from './SearchFilters.css';
|
|||
|
||||
// Dropdown container can have a positional offset (in pixels)
|
||||
const FILTER_DROPDOWN_OFFSET = -14;
|
||||
const RADIX = 10;
|
||||
|
||||
// resolve initial value for a single value filter
|
||||
const initialValue = (queryParams, paramName) => {
|
||||
|
|
@ -25,6 +26,18 @@ const initialValues = (queryParams, paramName) => {
|
|||
return !!queryParams[paramName] ? queryParams[paramName].split(',') : [];
|
||||
};
|
||||
|
||||
const initialPriceRangeValue = (queryParams, paramName) => {
|
||||
const price = queryParams[paramName];
|
||||
const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : [];
|
||||
|
||||
return !!price && valuesFromParams.length === 2
|
||||
? {
|
||||
minPrice: valuesFromParams[0],
|
||||
maxPrice: valuesFromParams[1],
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
const SearchFiltersComponent = props => {
|
||||
const {
|
||||
rootClassName,
|
||||
|
|
@ -35,6 +48,7 @@ const SearchFiltersComponent = props => {
|
|||
searchInProgress,
|
||||
categoryFilter,
|
||||
amenitiesFilter,
|
||||
priceFilter,
|
||||
isSearchFiltersPanelOpen,
|
||||
toggleSearchFiltersPanel,
|
||||
searchFiltersPanelSelectedCount,
|
||||
|
|
@ -61,6 +75,10 @@ const SearchFiltersComponent = props => {
|
|||
? initialValue(urlQueryParams, categoryFilter.paramName)
|
||||
: null;
|
||||
|
||||
const initialPriceRange = priceFilter
|
||||
? initialPriceRangeValue(urlQueryParams, priceFilter.paramName)
|
||||
: null;
|
||||
|
||||
const handleSelectOptions = (urlParam, options) => {
|
||||
const queryParams =
|
||||
options && options.length > 0
|
||||
|
|
@ -80,6 +98,16 @@ const SearchFiltersComponent = props => {
|
|||
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
|
||||
};
|
||||
|
||||
const handlePrice = (urlParam, range) => {
|
||||
const { minPrice, maxPrice } = range || {};
|
||||
const queryParams =
|
||||
minPrice != null && maxPrice != null
|
||||
? { ...urlQueryParams, [urlParam]: `${minPrice},${maxPrice}` }
|
||||
: omit(urlQueryParams, urlParam);
|
||||
|
||||
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
|
||||
};
|
||||
|
||||
const categoryFilterElement = categoryFilter ? (
|
||||
<SelectSingleFilter
|
||||
urlParam={categoryFilter.paramName}
|
||||
|
|
@ -104,6 +132,18 @@ const SearchFiltersComponent = props => {
|
|||
/>
|
||||
) : null;
|
||||
|
||||
const priceFilterElement = priceFilter ? (
|
||||
<PriceFilter
|
||||
id="SearchFilters.priceFilter"
|
||||
urlParam={priceFilter.paramName}
|
||||
onSubmit={handlePrice}
|
||||
showAsPopup
|
||||
{...priceFilter.config}
|
||||
initialValues={initialPriceRange}
|
||||
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
const toggleSearchFiltersPanelButtonClasses =
|
||||
isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0
|
||||
? css.searchFiltersPanelOpen
|
||||
|
|
@ -126,6 +166,7 @@ const SearchFiltersComponent = props => {
|
|||
<div className={css.filters}>
|
||||
{categoryFilterElement}
|
||||
{amenitiesFilterElement}
|
||||
{priceFilterElement}
|
||||
{toggleSearchFiltersPanelButton}
|
||||
</div>
|
||||
|
||||
|
|
@ -174,6 +215,7 @@ SearchFiltersComponent.propTypes = {
|
|||
onManageDisableScrolling: func.isRequired,
|
||||
categoriesFilter: propTypes.filterConfig,
|
||||
amenitiesFilter: propTypes.filterConfig,
|
||||
priceFilter: propTypes.filterConfig,
|
||||
isSearchFiltersPanelOpen: bool,
|
||||
toggleSearchFiltersPanel: func,
|
||||
searchFiltersPanelSelectedCount: number,
|
||||
|
|
|
|||
|
|
@ -11,12 +11,15 @@ import {
|
|||
SecondaryButton,
|
||||
ModalInMobile,
|
||||
Button,
|
||||
PriceFilter,
|
||||
SelectSingleFilterPlain,
|
||||
SelectMultipleFilterPlain,
|
||||
} from '../../components';
|
||||
import { propTypes } from '../../util/types';
|
||||
import css from './SearchFiltersMobile.css';
|
||||
|
||||
const RADIX = 10;
|
||||
|
||||
class SearchFiltersMobileComponent extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
|
@ -28,8 +31,10 @@ class SearchFiltersMobileComponent extends Component {
|
|||
this.resetAll = this.resetAll.bind(this);
|
||||
this.handleSelectSingle = this.handleSelectSingle.bind(this);
|
||||
this.handleSelectMultiple = this.handleSelectMultiple.bind(this);
|
||||
this.handlePrice = this.handlePrice.bind(this);
|
||||
this.initialValue = this.initialValue.bind(this);
|
||||
this.initialValues = this.initialValues.bind(this);
|
||||
this.initialPriceRangeValue = this.initialPriceRangeValue.bind(this);
|
||||
}
|
||||
|
||||
// Open filters modal, set the initial parameters to current ones
|
||||
|
|
@ -84,6 +89,17 @@ class SearchFiltersMobileComponent extends Component {
|
|||
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
|
||||
}
|
||||
|
||||
handlePrice(urlParam, range) {
|
||||
const { urlQueryParams, history } = this.props;
|
||||
const { minPrice, maxPrice } = range || {};
|
||||
const queryParams =
|
||||
minPrice != null && maxPrice != null
|
||||
? { ...urlQueryParams, [urlParam]: `${minPrice},${maxPrice}` }
|
||||
: omit(urlQueryParams, urlParam);
|
||||
|
||||
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
|
||||
}
|
||||
|
||||
// Reset all filter query parameters
|
||||
resetAll(e) {
|
||||
const { urlQueryParams, history, filterParamNames } = this.props;
|
||||
|
|
@ -108,6 +124,19 @@ class SearchFiltersMobileComponent extends Component {
|
|||
return !!urlQueryParams[paramName] ? urlQueryParams[paramName].split(',') : [];
|
||||
}
|
||||
|
||||
initialPriceRangeValue(paramName) {
|
||||
const urlQueryParams = this.props.urlQueryParams;
|
||||
const price = urlQueryParams[paramName];
|
||||
const valuesFromParams = !!price ? price.split(',').map(v => Number.parseInt(v, RADIX)) : [];
|
||||
|
||||
return !!price && valuesFromParams.length === 2
|
||||
? {
|
||||
minPrice: valuesFromParams[0],
|
||||
maxPrice: valuesFromParams[1],
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
rootClassName,
|
||||
|
|
@ -121,6 +150,7 @@ class SearchFiltersMobileComponent extends Component {
|
|||
selectedFiltersCount,
|
||||
categoryFilter,
|
||||
amenitiesFilter,
|
||||
priceFilter,
|
||||
intl,
|
||||
} = this.props;
|
||||
|
||||
|
|
@ -182,6 +212,19 @@ class SearchFiltersMobileComponent extends Component {
|
|||
/>
|
||||
) : null;
|
||||
|
||||
const initialPriceRange = this.initialPriceRangeValue(priceFilter.paramName);
|
||||
|
||||
const priceFilterElement = priceFilter ? (
|
||||
<PriceFilter
|
||||
id="SearchFiltersMobile.priceFilter"
|
||||
urlParam={priceFilter.paramName}
|
||||
onSubmit={this.handlePrice}
|
||||
liveEdit
|
||||
{...priceFilter.config}
|
||||
initialValues={initialPriceRange}
|
||||
/>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className={classes}>
|
||||
<div className={css.searchResultSummary}>
|
||||
|
|
@ -213,6 +256,7 @@ class SearchFiltersMobileComponent extends Component {
|
|||
<div className={css.filtersWrapper}>
|
||||
{categoryFilterElement}
|
||||
{amenitiesFilterElement}
|
||||
{priceFilterElement}
|
||||
</div>
|
||||
<div className={css.showListingsContainer}>
|
||||
<Button className={css.showListingsButton} onClick={this.closeFilters}>
|
||||
|
|
|
|||
|
|
@ -1,20 +0,0 @@
|
|||
/* eslint-disable no-console */
|
||||
import { fakeIntl } from '../../util/test-data';
|
||||
import TopbarDesktop from './TopbarDesktop';
|
||||
|
||||
const noop = () => null;
|
||||
|
||||
export const AuthenticatedDesktopTopbar = {
|
||||
component: TopbarDesktop,
|
||||
props: {
|
||||
isAuthenticated: true,
|
||||
currentUserHasListings: true,
|
||||
name: 'John Doe',
|
||||
onSearchSubmit: values => {
|
||||
console.log('submit search:', values);
|
||||
},
|
||||
intl: fakeIntl,
|
||||
onLogout: noop,
|
||||
},
|
||||
group: 'navigation',
|
||||
};
|
||||
|
|
@ -95,9 +95,11 @@ export { default as NotificationBadge } from './NotificationBadge/NotificationBa
|
|||
export { default as OrderDiscussionPanel } from './OrderDiscussionPanel/OrderDiscussionPanel';
|
||||
export { default as Page } from './Page/Page';
|
||||
export { default as PaginationLinks } from './PaginationLinks/PaginationLinks';
|
||||
export { default as PriceFilter } from './PriceFilter/PriceFilter';
|
||||
export { default as PrivacyPolicy } from './PrivacyPolicy/PrivacyPolicy';
|
||||
export { default as Promised } from './Promised/Promised';
|
||||
export { default as PropertyGroup } from './PropertyGroup/PropertyGroup';
|
||||
export { default as RangeSlider } from './RangeSlider/RangeSlider';
|
||||
export { default as ResponsiveImage } from './ResponsiveImage/ResponsiveImage';
|
||||
export { default as ReviewModal } from './ReviewModal/ReviewModal';
|
||||
export { default as ReviewRating } from './ReviewRating/ReviewRating';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import unionWith from 'lodash/unionWith';
|
||||
import { storableError } from '../../util/errors';
|
||||
import { addMarketplaceEntities } from '../../ducks/marketplaceData.duck';
|
||||
import { convertUnitToSubUnit, unitDivisor } from '../../util/currency';
|
||||
import config from '../../config';
|
||||
|
||||
// ================ Action types ================ //
|
||||
|
||||
|
|
@ -118,9 +120,22 @@ export const searchMapListingsError = e => ({
|
|||
export const searchListings = searchParams => (dispatch, getState, sdk) => {
|
||||
dispatch(searchListingsRequest(searchParams));
|
||||
|
||||
const { perPage, ...rest } = searchParams;
|
||||
const priceSearchParams = priceParam => {
|
||||
const inSubunits = value =>
|
||||
convertUnitToSubUnit(value, unitDivisor(config.currencyConfig.currency));
|
||||
const values = priceParam ? priceParam.split(',') : [];
|
||||
return priceParam && values.length === 2
|
||||
? {
|
||||
price: [inSubunits(values[0]), inSubunits(values[1]) + 1].join(','),
|
||||
}
|
||||
: {};
|
||||
};
|
||||
|
||||
const { perPage, price, ...rest } = searchParams;
|
||||
const priceMaybe = priceSearchParams(price);
|
||||
const params = {
|
||||
...rest,
|
||||
...priceMaybe,
|
||||
per_page: perPage,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,19 @@ export const validURLParamForExtendedData = (paramName, paramValue, filters) =>
|
|||
const valueArray = paramValue ? paramValue.split(',') : [];
|
||||
|
||||
if (filterConfig && valueArray.length > 0) {
|
||||
const allowedValues = filterConfig.options.map(o => o.key);
|
||||
const { min, max } = filterConfig.config || {};
|
||||
|
||||
const validValues = intersection(valueArray, allowedValues).join(',');
|
||||
return validValues.length > 0 ? { [paramName]: validValues } : {};
|
||||
if (filterConfig.options) {
|
||||
const allowedValues = filterConfig.options.map(o => o.key);
|
||||
|
||||
const validValues = intersection(valueArray, allowedValues).join(',');
|
||||
return validValues.length > 0 ? { [paramName]: validValues } : {};
|
||||
} else if (filterConfig.config && min != null && max != null) {
|
||||
const validValues = valueArray.map(v => {
|
||||
return v < min ? min : v > max ? max : v;
|
||||
});
|
||||
return validValues.length === 2 ? { [paramName]: validValues.join(',') } : {};
|
||||
}
|
||||
}
|
||||
return {};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { Component } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
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';
|
||||
|
|
@ -52,7 +52,7 @@ export class SearchPageComponent extends Component {
|
|||
}
|
||||
|
||||
filters() {
|
||||
const { categories, amenities } = this.props;
|
||||
const { categories, amenities, priceFilterConfig } = this.props;
|
||||
|
||||
return {
|
||||
categoryFilter: {
|
||||
|
|
@ -63,6 +63,10 @@ export class SearchPageComponent extends Component {
|
|||
paramName: 'pub_amenities',
|
||||
options: amenities,
|
||||
},
|
||||
priceFilter: {
|
||||
paramName: 'price',
|
||||
config: priceFilterConfig,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -205,6 +209,7 @@ export class SearchPageComponent extends Component {
|
|||
primaryFilters={{
|
||||
categoryFilter: filters.categoryFilter,
|
||||
amenitiesFilter: filters.amenitiesFilter,
|
||||
priceFilter: filters.priceFilter,
|
||||
}}
|
||||
/>
|
||||
<ModalInMobile
|
||||
|
|
@ -249,11 +254,10 @@ SearchPageComponent.defaultProps = {
|
|||
tab: 'listings',
|
||||
categories: config.custom.categories,
|
||||
amenities: config.custom.amenities,
|
||||
priceFilterConfig: config.custom.priceFilterConfig,
|
||||
activeListingId: null,
|
||||
};
|
||||
|
||||
const { array, bool, func, oneOf, object, shape, string } = PropTypes;
|
||||
|
||||
SearchPageComponent.propTypes = {
|
||||
listings: array,
|
||||
mapListings: array,
|
||||
|
|
@ -268,6 +272,11 @@ SearchPageComponent.propTypes = {
|
|||
tab: oneOf(['filters', 'listings', 'map']).isRequired,
|
||||
categories: array,
|
||||
amenities: array,
|
||||
priceFilterConfig: shape({
|
||||
min: number.isRequired,
|
||||
max: number.isRequired,
|
||||
step: number.isRequired,
|
||||
}),
|
||||
|
||||
// from withRouter
|
||||
history: shape({
|
||||
|
|
|
|||
|
|
@ -66,6 +66,14 @@ exports[`SearchPageComponent matches snapshot 1`] = `
|
|||
],
|
||||
"paramName": "pub_category",
|
||||
},
|
||||
"priceFilter": Object {
|
||||
"config": Object {
|
||||
"max": 1000,
|
||||
"min": 0,
|
||||
"step": 5,
|
||||
},
|
||||
"paramName": "price",
|
||||
},
|
||||
}
|
||||
}
|
||||
resultsCount={0}
|
||||
|
|
|
|||
|
|
@ -30,8 +30,8 @@ const Example = props => {
|
|||
className={css.link}
|
||||
>
|
||||
{componentName}
|
||||
</NamedLink>
|
||||
/
|
||||
</NamedLink>{' '}
|
||||
/{' '}
|
||||
<NamedLink
|
||||
name="StyleguideComponentExample"
|
||||
params={{ component: componentName, example: exampleName }}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import * as FieldCurrencyInput from './components/FieldCurrencyInput/FieldCurren
|
|||
import * as FieldDateInput from './components/FieldDateInput/FieldDateInput.example';
|
||||
import * as FieldDateRangeInput from './components/FieldDateRangeInput/FieldDateRangeInput.example';
|
||||
import * as FieldPhoneNumberInput from './components/FieldPhoneNumberInput/FieldPhoneNumberInput.example';
|
||||
import * as FieldRangeSlider from './components/FieldRangeSlider/FieldRangeSlider.example';
|
||||
import * as FieldReviewRating from './components/FieldReviewRating/FieldReviewRating.example';
|
||||
import * as FieldSelect from './components/FieldSelect/FieldSelect.example';
|
||||
import * as FieldTextInput from './components/FieldTextInput/FieldTextInput.example';
|
||||
|
|
@ -43,7 +44,9 @@ import * as Modal from './components/Modal/Modal.example';
|
|||
import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example';
|
||||
import * as NamedLink from './components/NamedLink/NamedLink.example';
|
||||
import * as PaginationLinks from './components/PaginationLinks/PaginationLinks.example';
|
||||
import * as PriceFilter from './components/PriceFilter/PriceFilter.example';
|
||||
import * as PropertyGroup from './components/PropertyGroup/PropertyGroup.example';
|
||||
import * as RangeSlider from './components/RangeSlider/RangeSlider.example';
|
||||
import * as ResponsiveImage from './components/ResponsiveImage/ResponsiveImage.example';
|
||||
import * as ReviewRating from './components/ReviewRating/ReviewRating.example';
|
||||
import * as Reviews from './components/Reviews/Reviews.example';
|
||||
|
|
@ -54,7 +57,6 @@ import * as StripeBankAccountTokenInputField from './components/StripeBankAccoun
|
|||
import * as TabNav from './components/TabNav/TabNav.example';
|
||||
import * as TabNavHorizontal from './components/TabNavHorizontal/TabNavHorizontal.example';
|
||||
import * as Tabs from './components/Tabs/Tabs.example';
|
||||
import * as TopbarDesktop from './components/TopbarDesktop/TopbarDesktop.example';
|
||||
import * as UserCard from './components/UserCard/UserCard.example';
|
||||
|
||||
// forms
|
||||
|
|
@ -105,6 +107,7 @@ export {
|
|||
FieldDateInput,
|
||||
FieldDateRangeInput,
|
||||
FieldPhoneNumberInput,
|
||||
FieldRangeSlider,
|
||||
FieldReviewRating,
|
||||
FieldSelect,
|
||||
FieldTextInput,
|
||||
|
|
@ -139,7 +142,9 @@ export {
|
|||
PasswordRecoveryForm,
|
||||
PasswordResetForm,
|
||||
PayoutDetailsForm,
|
||||
PriceFilter,
|
||||
PropertyGroup,
|
||||
RangeSlider,
|
||||
ResponsiveImage,
|
||||
ReviewForm,
|
||||
ReviewRating,
|
||||
|
|
@ -154,7 +159,6 @@ export {
|
|||
TabNav,
|
||||
TabNavHorizontal,
|
||||
Tabs,
|
||||
TopbarDesktop,
|
||||
Typography,
|
||||
UserCard,
|
||||
};
|
||||
|
|
|
|||
192
src/forms/PriceFilterForm/PriceFilterForm.css
Normal file
192
src/forms/PriceFilterForm/PriceFilterForm.css
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
@import '../../marketplace.css';
|
||||
|
||||
.root {
|
||||
display: none;
|
||||
|
||||
/* Borders */
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.popup {
|
||||
/* By default hide the content */
|
||||
display: block;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
|
||||
/* Position */
|
||||
position: absolute;
|
||||
z-index: var(--zIndexPopup);
|
||||
|
||||
/* Layout */
|
||||
padding: 15px 30px 20px 30px;
|
||||
margin-top: 7px;
|
||||
background-color: var(--matterColorLight);
|
||||
|
||||
/* Borders */
|
||||
border-top: 1px solid var(--matterColorNegative);
|
||||
box-shadow: var(--boxShadowPopup);
|
||||
border-radius: 4px;
|
||||
transition: var(--transitionStyleButton);
|
||||
}
|
||||
|
||||
.isOpenAsPopup {
|
||||
display: block;
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.plain {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.isOpen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
max-width: 300px;
|
||||
padding-top: 5px;
|
||||
padding-bottom: 5px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
@media (--viewportMedium) {
|
||||
.contentWrapper {
|
||||
padding-top: 7px;
|
||||
padding-bottom: 7px;
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply --marketplaceSearchFilterSublabelFontStyles;
|
||||
padding: 8px 0 12px 0;
|
||||
margin-right: 18px;
|
||||
}
|
||||
@media (--viewportMedium) {
|
||||
.label {
|
||||
padding: 8px 0 16px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.inputsWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.minPrice {
|
||||
@apply --marketplaceSearchFilterSublabelFontStyles;
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
border-bottom-color: var(--attentionColor);
|
||||
border-bottom-width: 3px;
|
||||
|
||||
-moz-appearance: textfield;
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
.maxPrice {
|
||||
@apply --marketplaceSearchFilterSublabelFontStyles;
|
||||
width: 48px;
|
||||
text-align: center;
|
||||
border-bottom-color: var(--attentionColor);
|
||||
border-bottom-width: 3px;
|
||||
|
||||
-moz-appearance: textfield;
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.priceSeparator {
|
||||
margin: 0 8px;
|
||||
}
|
||||
|
||||
.sliderWrapper {
|
||||
display: flex;
|
||||
padding: 17px 0 25px 0;
|
||||
}
|
||||
@media (--viewportMedium) {
|
||||
.sliderWrapper {
|
||||
padding: 16px 0 24px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.buttonsWrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
/* Layout */
|
||||
margin: 0 auto 0 0;
|
||||
padding: 0;
|
||||
|
||||
/* Override button styles */
|
||||
outline: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--matterColor);
|
||||
transition: width var(--transitionStyleButton);
|
||||
}
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
color: var(--matterColorAnti);
|
||||
|
||||
/* Layout */
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
/* Override button styles */
|
||||
outline: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
/* clearButton will add all available space between cancelButton,
|
||||
* but some hard coded margin is still needed
|
||||
*/
|
||||
margin-left: 48px;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--matterColor);
|
||||
transition: width var(--transitionStyleButton);
|
||||
}
|
||||
}
|
||||
|
||||
.submitButton {
|
||||
@apply --marketplaceH4FontStyles;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
color: var(--marketplaceColor);
|
||||
|
||||
/* Layout */
|
||||
margin: 0 0 0 19px;
|
||||
padding: 0;
|
||||
|
||||
/* Override button styles */
|
||||
outline: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
color: var(--marketplaceColorDark);
|
||||
transition: width var(--transitionStyleButton);
|
||||
}
|
||||
}
|
||||
209
src/forms/PriceFilterForm/PriceFilterForm.js
Normal file
209
src/forms/PriceFilterForm/PriceFilterForm.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import React from 'react';
|
||||
import { bool, func, number, object, string } from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { Field, Form as FinalForm, FormSpy } from 'react-final-form';
|
||||
import { injectIntl, intlShape, FormattedMessage } from 'react-intl';
|
||||
|
||||
import { Form, RangeSlider } from '../../components';
|
||||
import css from './PriceFilterForm.css';
|
||||
|
||||
const DEBOUNCE_WAIT_TIME = 400;
|
||||
|
||||
// Helper function to parse value for min handle
|
||||
// Value needs to be between slider's minimum value and current maximum value
|
||||
const parseMin = (min, currentMax) => value => {
|
||||
if (isNaN(value)) {
|
||||
return min;
|
||||
}
|
||||
const parsedValue = Number.parseInt(value, 10);
|
||||
return parsedValue < min ? min : parsedValue > currentMax ? currentMax : parsedValue;
|
||||
};
|
||||
|
||||
// Helper function to parse value for max handle
|
||||
// Value needs to be between slider's max value and current minimum value
|
||||
const parseMax = (max, currentMin) => value => {
|
||||
if (isNaN(value)) {
|
||||
return max;
|
||||
}
|
||||
const parsedValue = Number.parseInt(value, 10);
|
||||
return parsedValue < currentMin ? currentMin : parsedValue > max ? max : parsedValue;
|
||||
};
|
||||
|
||||
// PriceFilterForm component
|
||||
const PriceFilterFormComponent = props => {
|
||||
const { liveEdit, onChange, onSubmit, onCancel, onClear, ...rest } = props;
|
||||
|
||||
if (liveEdit && !onChange) {
|
||||
throw new Error('PriceFilterForm: if liveEdit is true you need to provide onChange function');
|
||||
}
|
||||
|
||||
if (!liveEdit && !(onCancel && onClear && onSubmit)) {
|
||||
throw new Error(
|
||||
'PriceFilterForm: if liveEdit is false you need to provide onCancel, onClear, and onSubmit functions'
|
||||
);
|
||||
}
|
||||
|
||||
const handleChange = debounce(
|
||||
formState => {
|
||||
if (formState.dirty) {
|
||||
onChange(formState.values);
|
||||
}
|
||||
},
|
||||
DEBOUNCE_WAIT_TIME,
|
||||
{ leading: false, trailing: true }
|
||||
);
|
||||
|
||||
const formCallbacks = liveEdit ? { onSubmit: () => null } : { onSubmit, onCancel, onClear };
|
||||
return (
|
||||
<FinalForm
|
||||
{...rest}
|
||||
{...formCallbacks}
|
||||
render={formRenderProps => {
|
||||
const {
|
||||
form,
|
||||
handleSubmit,
|
||||
id,
|
||||
showAsPopup,
|
||||
onClear,
|
||||
onCancel,
|
||||
isOpen,
|
||||
contentRef,
|
||||
style,
|
||||
intl,
|
||||
values,
|
||||
min,
|
||||
max,
|
||||
step,
|
||||
} = formRenderProps;
|
||||
const { minPrice, maxPrice } = values;
|
||||
|
||||
const handleCancel = () => {
|
||||
// reset the final form to initialValues
|
||||
form.reset();
|
||||
onCancel();
|
||||
};
|
||||
|
||||
const clear = intl.formatMessage({ id: 'PriceFilterForm.clear' });
|
||||
const cancel = intl.formatMessage({ id: 'PriceFilterForm.cancel' });
|
||||
const submit = intl.formatMessage({ id: 'PriceFilterForm.submit' });
|
||||
|
||||
const classes = classNames(css.root, {
|
||||
[css.popup]: showAsPopup,
|
||||
[css.isOpenAsPopup]: showAsPopup && isOpen,
|
||||
[css.plain]: !showAsPopup,
|
||||
[css.isOpen]: !showAsPopup && isOpen,
|
||||
});
|
||||
|
||||
return (
|
||||
<Form
|
||||
className={classes}
|
||||
onSubmit={handleSubmit}
|
||||
tabIndex="0"
|
||||
contentRef={contentRef}
|
||||
style={{ minWidth: '300px', ...style }}
|
||||
>
|
||||
<div className={css.contentWrapper}>
|
||||
<span className={css.label}>
|
||||
<FormattedMessage id="PriceFilterForm.label" />
|
||||
</span>
|
||||
<div className={css.inputsWrapper}>
|
||||
<Field
|
||||
className={css.minPrice}
|
||||
id={`${id}.minPrice`}
|
||||
name="minPrice"
|
||||
component="input"
|
||||
type="number"
|
||||
placeholder={min}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
parse={parseMin(min, maxPrice)}
|
||||
/>
|
||||
<span className={css.priceSeparator}>-</span>
|
||||
<Field
|
||||
className={css.maxPrice}
|
||||
id={`${id}.maxPrice`}
|
||||
name="maxPrice"
|
||||
component="input"
|
||||
type="number"
|
||||
placeholder={max}
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
parse={parseMax(max, minPrice)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={css.sliderWrapper}>
|
||||
<RangeSlider
|
||||
min={min}
|
||||
max={max}
|
||||
step={step}
|
||||
handles={[minPrice, maxPrice]}
|
||||
onChange={handles => {
|
||||
form.change('minPrice', handles[0]);
|
||||
form.change('maxPrice', handles[1]);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{liveEdit ? (
|
||||
<FormSpy onChange={handleChange} subscription={{ values: true, dirty: true }} />
|
||||
) : (
|
||||
<div className={css.buttonsWrapper}>
|
||||
<button className={css.clearButton} type="button" onClick={onClear}>
|
||||
{clear}
|
||||
</button>
|
||||
<button className={css.cancelButton} type="button" onClick={handleCancel}>
|
||||
{cancel}
|
||||
</button>
|
||||
<button className={css.submitButton} type="submit">
|
||||
{submit}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
PriceFilterFormComponent.defaultProps = {
|
||||
liveEdit: false,
|
||||
showAsPopup: false,
|
||||
isOpen: false,
|
||||
contentRef: null,
|
||||
style: null,
|
||||
min: 0,
|
||||
step: 1,
|
||||
onCancel: null,
|
||||
onChange: null,
|
||||
onClear: null,
|
||||
onSubmit: null,
|
||||
};
|
||||
|
||||
PriceFilterFormComponent.propTypes = {
|
||||
id: string.isRequired,
|
||||
liveEdit: bool,
|
||||
showAsPopup: bool,
|
||||
onCancel: func,
|
||||
onChange: func,
|
||||
onClear: func,
|
||||
onSubmit: func,
|
||||
isOpen: bool,
|
||||
contentRef: func,
|
||||
style: object,
|
||||
min: number.isRequired,
|
||||
max: number.isRequired,
|
||||
step: number,
|
||||
|
||||
// form injectIntl
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
|
||||
const PriceFilterForm = injectIntl(PriceFilterFormComponent);
|
||||
|
||||
export default PriceFilterForm;
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
|
||||
/* Layout */
|
||||
margin-top: 7px;
|
||||
padding: 15px 40px 20px 30px;
|
||||
padding: 15px 30px 20px 30px;
|
||||
|
||||
/* Borders */
|
||||
background-color: var(--matterColorLight);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export { default as PasswordChangeForm } from './PasswordChangeForm/PasswordChan
|
|||
export { default as PasswordRecoveryForm } from './PasswordRecoveryForm/PasswordRecoveryForm';
|
||||
export { default as PasswordResetForm } from './PasswordResetForm/PasswordResetForm';
|
||||
export { default as PayoutDetailsForm } from './PayoutDetailsForm/PayoutDetailsForm';
|
||||
export { default as PriceFilterForm } from './PriceFilterForm/PriceFilterForm';
|
||||
export { default as ProfileSettingsForm } from './ProfileSettingsForm/ProfileSettingsForm';
|
||||
export { default as ReviewForm } from './ReviewForm/ReviewForm';
|
||||
export { default as SendMessageForm } from './SendMessageForm/SendMessageForm';
|
||||
|
|
|
|||
|
|
@ -43,3 +43,11 @@ export const categories = [
|
|||
{ key: 'wood', label: 'Wood' },
|
||||
{ key: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
// Price filter configuration
|
||||
// Note: unlike most prices this is not handled in subunits
|
||||
export const priceFilterConfig = {
|
||||
min: 0,
|
||||
max: 1000,
|
||||
step: 5,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -271,6 +271,13 @@
|
|||
}
|
||||
}
|
||||
|
||||
--marketplaceSearchFilterSublabelFontStyles {
|
||||
font-family: 'sofiapro', Helvetica, Arial, sans-serif;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
font-size: 18px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
--marketplaceListingAttributeFontStyles {
|
||||
font-family: 'sofiapro', Helvetica, Arial, sans-serif;
|
||||
font-weight: var(--fontWeightMedium);
|
||||
|
|
|
|||
|
|
@ -476,6 +476,14 @@
|
|||
"PayoutPreferencesPage.stripeNotConnected": "Payment information not saved. Please fill in the form to accept payments from your listings.",
|
||||
"PayoutPreferencesPage.submitButtonText": "Save details",
|
||||
"PayoutPreferencesPage.title": "Payment settings",
|
||||
"PriceFilter.clear": "Clear",
|
||||
"PriceFilter.label": "Price",
|
||||
"PriceFilter.labelSelectedPlain": "Price: {minPrice} - {maxPrice}",
|
||||
"PriceFilter.labelSelectedButton": "{minPrice} - {maxPrice}",
|
||||
"PriceFilterForm.cancel": "Cancel",
|
||||
"PriceFilterForm.clear": "Clear",
|
||||
"PriceFilterForm.label": "Price range:",
|
||||
"PriceFilterForm.submit": "Apply",
|
||||
"PrivacyPolicyPage.heading": "Saunatime Privacy Policy",
|
||||
"PrivacyPolicyPage.privacyTabTitle": "Privacy Policy",
|
||||
"PrivacyPolicyPage.schemaTitle": "Privacy Policy | {siteTitle}",
|
||||
|
|
|
|||
|
|
@ -69,6 +69,112 @@ export const withViewport = Component => {
|
|||
return WithViewportComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* A higher order component (HOC) that provides dimensions to the wrapped component as a
|
||||
* `dimensions` prop that has the shape `{ width: 600, height: 400}`.
|
||||
*
|
||||
* @param {React.Component} Component to be wrapped by this HOC
|
||||
* @param {Object} options pass in options like maxWidth and maxHeight.
|
||||
*
|
||||
* @return {Object} HOC component which knows its dimensions
|
||||
*/
|
||||
export const withDimensions = (Component, options = {}) => {
|
||||
// The resize event is flooded when the browser is resized. We'll
|
||||
// use a small timeout to throttle changing the viewport since it
|
||||
// will trigger rerendering.
|
||||
const THROTTLE_WAIT_MS = 200;
|
||||
// First render default wait after mounting (small wait for styled paint)
|
||||
const RENDER_WAIT_MS = 100;
|
||||
|
||||
class WithDimensionsComponent extends ReactComponent {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.element = null;
|
||||
this.defaultRenderTimeout = null;
|
||||
|
||||
this.state = { width: 0, height: 0 };
|
||||
|
||||
this.handleWindowResize = throttle(this.handleWindowResize.bind(this), THROTTLE_WAIT_MS);
|
||||
this.setDimensions = this.setDimensions.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
window.addEventListener('resize', this.handleWindowResize);
|
||||
window.addEventListener('orientationchange', this.handleWindowResize);
|
||||
|
||||
this.defaultRenderTimeout = window.setTimeout(() => {
|
||||
this.setDimensions();
|
||||
}, RENDER_WAIT_MS);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
window.removeEventListener('resize', this.handleWindowResize);
|
||||
window.removeEventListener('orientationchange', this.handleWindowResize);
|
||||
window.clearTimeout(this.defaultRenderTimeout);
|
||||
}
|
||||
|
||||
handleWindowResize() {
|
||||
window.requestAnimationFrame(() => {
|
||||
this.setDimensions();
|
||||
});
|
||||
}
|
||||
|
||||
setDimensions() {
|
||||
this.setState(prevState => {
|
||||
const { clientWidth, clientHeight } = this.element || { clientWidth: 0, clientHeight: 0 };
|
||||
return { width: clientWidth, height: clientHeight };
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
// Dimensions from state (i.e. dimension after previous resize)
|
||||
// These are needed for component rerenders
|
||||
const { width, height } = this.state;
|
||||
|
||||
// Current dimensions from element reference
|
||||
const { clientWidth, clientHeight } = this.element || { clientWidth: 0, clientHeight: 0 };
|
||||
const hasDimensions =
|
||||
(width !== 0 && height !== 0) || (clientWidth !== 0 && clientHeight !== 0);
|
||||
|
||||
// clientWidth and clientHeight
|
||||
const currentDimensions =
|
||||
clientWidth !== 0 && clientHeight !== 0
|
||||
? { width: clientWidth, height: clientHeight }
|
||||
: width !== 0 && height !== 0
|
||||
? { width, height }
|
||||
: {};
|
||||
|
||||
const props = { ...this.props, dimensions: currentDimensions };
|
||||
|
||||
// lazyLoadWithDimensions HOC needs to take all given space
|
||||
// unless max dimensions are provided through options.
|
||||
const { maxWidth, maxHeight } = options;
|
||||
const maxWidthMaybe = maxWidth ? { maxWidth } : {};
|
||||
const maxHeightMaybe = maxHeight ? { maxHeight } : {};
|
||||
const style =
|
||||
maxWidth || maxHeight
|
||||
? { width: '100%', height: '100%', ...maxWidthMaybe, ...maxHeightMaybe }
|
||||
: { position: 'absolute', top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={element => {
|
||||
this.element = element;
|
||||
}}
|
||||
style={style}
|
||||
>
|
||||
{hasDimensions ? <Component {...props} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
WithDimensionsComponent.displayName = `withDimensions(${Component.displayName ||
|
||||
Component.name})`;
|
||||
|
||||
return WithDimensionsComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* A higher order component (HOC) that lazy loads the current element and provides
|
||||
* dimensions to the wrapped component as a `dimensions` prop that has
|
||||
|
|
|
|||
|
|
@ -258,3 +258,30 @@ export const formatMoney = (intl, value) => {
|
|||
|
||||
return intl.formatNumber(valueAsNumber, numberFormatOptions);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format the given major-unit string value as currency. E.g. "10" -> "$10".
|
||||
*
|
||||
* NOTE: This function should not be used with listing prices or other Money type.
|
||||
* This can be used with price filters and other components that doesn't send Money types to API.
|
||||
*
|
||||
* @param {Object} intl
|
||||
* @param {String} value
|
||||
*
|
||||
* @return {String} formatted money value
|
||||
*/
|
||||
export const formatCurrencyMajorUnit = (intl, currency, valueWithoutSubunits) => {
|
||||
const valueAsNumber = new Decimal(valueWithoutSubunits).toNumber();
|
||||
|
||||
// See: https://github.com/yahoo/react-intl/wiki/API#formatnumber
|
||||
const numberFormatOptions = {
|
||||
style: 'currency',
|
||||
currency,
|
||||
currencyDisplay: 'symbol',
|
||||
useGrouping: true,
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
};
|
||||
|
||||
return intl.formatNumber(valueAsNumber, numberFormatOptions);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -415,7 +415,7 @@ propTypes.pagination = shape({
|
|||
});
|
||||
|
||||
// Search filter definition
|
||||
propTypes.filterConfig = shape({
|
||||
const filterWithOptions = shape({
|
||||
paramName: string.isRequired,
|
||||
options: arrayOf(
|
||||
shape({
|
||||
|
|
@ -424,6 +424,16 @@ propTypes.filterConfig = shape({
|
|||
})
|
||||
).isRequired,
|
||||
});
|
||||
const filterWithPriceConfig = shape({
|
||||
paramName: string.isRequired,
|
||||
config: shape({
|
||||
min: number.isRequired,
|
||||
max: number.isRequired,
|
||||
step: number.isRequired,
|
||||
}).isRequired,
|
||||
});
|
||||
|
||||
propTypes.filterConfig = oneOfType([filterWithOptions, filterWithPriceConfig]);
|
||||
|
||||
export const ERROR_CODE_TRANSACTION_LISTING_NOT_FOUND = 'transaction-listing-not-found';
|
||||
export const ERROR_CODE_TRANSACTION_INVALID_TRANSITION = 'transaction-invalid-transition';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue