Merge pull request #944 from sharetribe/pricing-filter

Pricing filter
This commit is contained in:
Vesa Luusua 2018-10-31 15:36:05 +02:00 committed by GitHub
commit 5db24d7770
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1827 additions and 46 deletions

View file

@ -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)

View file

@ -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`

View 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',
};

View 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;

View file

@ -0,0 +1,3 @@
.example {
overflow-x: hidden;
}

View file

@ -1,6 +1,7 @@
import Footer from './Footer';
import css from './Footer.example.css';
export const Default = {
component: Footer,
props: {},
props: { className: css.example },
};

View file

@ -0,0 +1,12 @@
@import '../../marketplace.css';
.root {
position: relative;
width: 100%;
height: 100%;
padding: 0;
@media (--viewportMedium) {
padding: 100px 10vw;
}
}

View file

@ -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 },
};

View file

@ -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>
);

View 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',
};

View 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;

View 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);
}
}

View 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;

View 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);
}
}

View 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);

View 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);
}

View 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;

View file

@ -0,0 +1,12 @@
@import '../../marketplace.css';
.root {
position: relative;
margin: 0 10px;
width: calc(100% - 20px);
height: 24px;
}
.activeHandle {
z-index: 1;
}

View 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',
};

View 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;

View 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);
}

View 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;

View file

@ -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,

View file

@ -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}>

View file

@ -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',
};

View file

@ -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';

View file

@ -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,
};

View file

@ -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 {};
};

View file

@ -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({

View file

@ -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}

View file

@ -30,8 +30,8 @@ const Example = props => {
className={css.link}
>
{componentName}
</NamedLink>
/
</NamedLink>{' '}
/{' '}
<NamedLink
name="StyleguideComponentExample"
params={{ component: componentName, example: exampleName }}

View file

@ -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,
};

View 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);
}
}

View 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;

View file

@ -12,7 +12,7 @@
/* Layout */
margin-top: 7px;
padding: 15px 40px 20px 30px;
padding: 15px 30px 20px 30px;
/* Borders */
background-color: var(--matterColorLight);

View file

@ -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';

View file

@ -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,
};

View file

@ -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);

View file

@ -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}",

View file

@ -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

View file

@ -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);
};

View file

@ -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';