diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dbddcb8..914dcf1b 100644 --- a/CHANGELOG.md +++ b/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) diff --git a/docs/search-filters.md b/docs/search-filters.md index a5bec48b..404b3082 100644 --- a/docs/search-filters.md +++ b/docs/search-filters.md @@ -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` diff --git a/src/components/FieldRangeSlider/FieldRangeSlider.example.js b/src/components/FieldRangeSlider/FieldRangeSlider.example.js new file mode 100644 index 00000000..ed3af2f5 --- /dev/null +++ b/src/components/FieldRangeSlider/FieldRangeSlider.example.js @@ -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 => ( + { + const { + formId, + handleSubmit, + onChange, + invalid, + pristine, + submitting, + min, + max, + step, + handles, + } = fieldRenderProps; + const submitDisabled = invalid || pristine || submitting; + + return ( +
{ + e.preventDefault(); + handleSubmit(e); + }} + > + + + + + + + ); + }} + /> +); + +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', +}; diff --git a/src/components/FieldRangeSlider/FieldRangeSlider.js b/src/components/FieldRangeSlider/FieldRangeSlider.js new file mode 100644 index 00000000..e18b09b1 --- /dev/null +++ b/src/components/FieldRangeSlider/FieldRangeSlider.js @@ -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 ; +}; + +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 ( +
+ {label ? : null} + +
+ ); +}; + +export default FieldRangeSlider; diff --git a/src/components/Footer/Footer.example.css b/src/components/Footer/Footer.example.css new file mode 100644 index 00000000..036e3cc1 --- /dev/null +++ b/src/components/Footer/Footer.example.css @@ -0,0 +1,3 @@ +.example { + overflow-x: hidden; +} diff --git a/src/components/Footer/Footer.example.js b/src/components/Footer/Footer.example.js index 8ed62725..4a7da2e8 100644 --- a/src/components/Footer/Footer.example.js +++ b/src/components/Footer/Footer.example.js @@ -1,6 +1,7 @@ import Footer from './Footer'; +import css from './Footer.example.css'; export const Default = { component: Footer, - props: {}, + props: { className: css.example }, }; diff --git a/src/components/ImageCarousel/ImageCarousel.example.css b/src/components/ImageCarousel/ImageCarousel.example.css new file mode 100644 index 00000000..a4187e9e --- /dev/null +++ b/src/components/ImageCarousel/ImageCarousel.example.css @@ -0,0 +1,12 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + width: 100%; + height: 100%; + padding: 0; + + @media (--viewportMedium) { + padding: 100px 10vw; + } +} diff --git a/src/components/ImageCarousel/ImageCarousel.example.js b/src/components/ImageCarousel/ImageCarousel.example.js index 94229a49..3831dd8e 100644 --- a/src/components/ImageCarousel/ImageCarousel.example.js +++ b/src/components/ImageCarousel/ImageCarousel.example.js @@ -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 }, }; diff --git a/src/components/Menu/Menu.example.js b/src/components/Menu/Menu.example.js index 7b94ba45..791b9087 100644 --- a/src/components/Menu/Menu.example.js +++ b/src/components/Menu/Menu.example.js @@ -44,7 +44,7 @@ export const MenuBasic = { const MenuOnRight = () => { return ( -
+
); diff --git a/src/components/PriceFilter/PriceFilter.example.js b/src/components/PriceFilter/PriceFilter.example.js new file mode 100644 index 00000000..029ad18d --- /dev/null +++ b/src/components/PriceFilter/PriceFilter.example.js @@ -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 ( + { + 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', +}; diff --git a/src/components/PriceFilter/PriceFilter.js b/src/components/PriceFilter/PriceFilter.js new file mode 100644 index 00000000..0a165930 --- /dev/null +++ b/src/components/PriceFilter/PriceFilter.js @@ -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 ? : ; +}; + +PriceFilter.defaultProps = { + showAsPopup: false, +}; + +PriceFilter.propTypes = { + showAsPopup: bool, +}; + +export default PriceFilter; diff --git a/src/components/PriceFilter/PriceFilterPlain.css b/src/components/PriceFilter/PriceFilterPlain.css new file mode 100644 index 00000000..1fae6d29 --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPlain.css @@ -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); + } +} diff --git a/src/components/PriceFilter/PriceFilterPlain.js b/src/components/PriceFilter/PriceFilterPlain.js new file mode 100644 index 00000000..89711b22 --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPlain.js @@ -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 ( +
+
+ + +
+ { + this.filterContent = node; + }} + min={min} + max={max} + step={step} + liveEdit + isOpen={this.state.isOpen} + /> +
+ ); + } +} + +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; diff --git a/src/components/PriceFilter/PriceFilterPopup.css b/src/components/PriceFilter/PriceFilterPopup.css new file mode 100644 index 00000000..67812d59 --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPopup.css @@ -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); + } +} diff --git a/src/components/PriceFilter/PriceFilterPopup.js b/src/components/PriceFilter/PriceFilterPopup.js new file mode 100644 index 00000000..b50e84eb --- /dev/null +++ b/src/components/PriceFilter/PriceFilterPopup.js @@ -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 ( +
{ + this.filter = node; + }} + > + + { + this.filterContent = node; + }} + style={contentStyle} + min={min} + max={max} + step={step} + showAsPopup + isOpen={this.state.isOpen} + /> +
+ ); + } +} + +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); diff --git a/src/components/RangeSlider/Handle.css b/src/components/RangeSlider/Handle.css new file mode 100644 index 00000000..25f50f29 --- /dev/null +++ b/src/components/RangeSlider/Handle.css @@ -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); +} diff --git a/src/components/RangeSlider/Handle.js b/src/components/RangeSlider/Handle.js new file mode 100644 index 00000000..35bba6fa --- /dev/null +++ b/src/components/RangeSlider/Handle.js @@ -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 ( +
+
+
+ ); + } +} + +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; diff --git a/src/components/RangeSlider/RangeSlider.css b/src/components/RangeSlider/RangeSlider.css new file mode 100644 index 00000000..472de977 --- /dev/null +++ b/src/components/RangeSlider/RangeSlider.css @@ -0,0 +1,12 @@ +@import '../../marketplace.css'; + +.root { + position: relative; + margin: 0 10px; + width: calc(100% - 20px); + height: 24px; +} + +.activeHandle { + z-index: 1; +} diff --git a/src/components/RangeSlider/RangeSlider.example.js b/src/components/RangeSlider/RangeSlider.example.js new file mode 100644 index 00000000..19a3cf08 --- /dev/null +++ b/src/components/RangeSlider/RangeSlider.example.js @@ -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 ( + { + 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', +}; diff --git a/src/components/RangeSlider/RangeSlider.js b/src/components/RangeSlider/RangeSlider.js new file mode 100644 index 00000000..8e9cb151 --- /dev/null +++ b/src/components/RangeSlider/RangeSlider.js @@ -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 ( + + {handles.map((h, index) => { + const classes = classNames({ [css.activeHandle]: this.state.activeHandle === index }); + return ( + this.changeActive(index)} + onChange={value => this.onChange(value, index)} + /> + ); + })} + + ); + } +} + +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 ( +
+ +
+ ); +}; + +RangeSlider.defaultProps = { + rootClassName: null, + className: null, +}; + +RangeSlider.propTypes = { + rootClassName: string, + className: string, +}; + +export default RangeSlider; diff --git a/src/components/RangeSlider/Track.css b/src/components/RangeSlider/Track.css new file mode 100644 index 00000000..e55cbe23 --- /dev/null +++ b/src/components/RangeSlider/Track.css @@ -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); +} diff --git a/src/components/RangeSlider/Track.js b/src/components/RangeSlider/Track.js new file mode 100644 index 00000000..28bc1ecc --- /dev/null +++ b/src/components/RangeSlider/Track.js @@ -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 ( +
+
+ + {handles.reduce((ranges, h, index) => { + return index < handles.length - 1 + ? [ + ...ranges, +
, + ] + : ranges; + }, [])} + + {children} +
+ ); + } +} + +Track.defaultProps = { + rootClassName: null, + className: null, + children: null, + handles: [], +}; + +Track.propTypes = { + rootClassName: string, + className: string, + children: node, + handles: array, +}; + +export default Track; diff --git a/src/components/SearchFilters/SearchFilters.js b/src/components/SearchFilters/SearchFilters.js index 4b0f213b..4bd1906f 100644 --- a/src/components/SearchFilters/SearchFilters.js +++ b/src/components/SearchFilters/SearchFilters.js @@ -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 ? ( { /> ) : null; + const priceFilterElement = priceFilter ? ( + + ) : null; + const toggleSearchFiltersPanelButtonClasses = isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0 ? css.searchFiltersPanelOpen @@ -126,6 +166,7 @@ const SearchFiltersComponent = props => {
{categoryFilterElement} {amenitiesFilterElement} + {priceFilterElement} {toggleSearchFiltersPanelButton}
@@ -174,6 +215,7 @@ SearchFiltersComponent.propTypes = { onManageDisableScrolling: func.isRequired, categoriesFilter: propTypes.filterConfig, amenitiesFilter: propTypes.filterConfig, + priceFilter: propTypes.filterConfig, isSearchFiltersPanelOpen: bool, toggleSearchFiltersPanel: func, searchFiltersPanelSelectedCount: number, diff --git a/src/components/SearchFiltersMobile/SearchFiltersMobile.js b/src/components/SearchFiltersMobile/SearchFiltersMobile.js index d175ad9c..a1cfb102 100644 --- a/src/components/SearchFiltersMobile/SearchFiltersMobile.js +++ b/src/components/SearchFiltersMobile/SearchFiltersMobile.js @@ -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 ? ( + + ) : null; + return (
@@ -213,6 +256,7 @@ class SearchFiltersMobileComponent extends Component {
{categoryFilterElement} {amenitiesFilterElement} + {priceFilterElement}
+ + +
+ )} + + ); + }} + /> + ); +}; + +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; diff --git a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css index 2d6f57c3..563539f7 100644 --- a/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css +++ b/src/forms/SelectMultipleFilterForm/SelectMultipleFilterForm.css @@ -12,7 +12,7 @@ /* Layout */ margin-top: 7px; - padding: 15px 40px 20px 30px; + padding: 15px 30px 20px 30px; /* Borders */ background-color: var(--matterColorLight); diff --git a/src/forms/index.js b/src/forms/index.js index e25116d7..00f25fd2 100644 --- a/src/forms/index.js +++ b/src/forms/index.js @@ -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'; diff --git a/src/marketplace-custom-config.js b/src/marketplace-custom-config.js index c07f7152..cd717a38 100644 --- a/src/marketplace-custom-config.js +++ b/src/marketplace-custom-config.js @@ -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, +}; diff --git a/src/marketplaceFonts.css b/src/marketplaceFonts.css index 1631adfe..7767fff1 100644 --- a/src/marketplaceFonts.css +++ b/src/marketplaceFonts.css @@ -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); diff --git a/src/translations/en.json b/src/translations/en.json index edec37eb..4a745a12 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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}", diff --git a/src/util/contextHelpers.js b/src/util/contextHelpers.js index a72b0da6..9f82c906 100644 --- a/src/util/contextHelpers.js +++ b/src/util/contextHelpers.js @@ -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 ( +
{ + this.element = element; + }} + style={style} + > + {hasDimensions ? : null} +
+ ); + } + } + + 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 diff --git a/src/util/currency.js b/src/util/currency.js index fd21af9a..fb09dc3d 100644 --- a/src/util/currency.js +++ b/src/util/currency.js @@ -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); +}; diff --git a/src/util/types.js b/src/util/types.js index 81a4daa4..5b643c82 100644 --- a/src/util/types.js +++ b/src/util/types.js @@ -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';