Merge pull request #1129 from sharetribe/keyword-search-filter-desktop

Add keyword search
This commit is contained in:
Vesa Luusua 2019-07-18 17:49:55 +03:00 committed by GitHub
commit bd76364769
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 500 additions and 7 deletions

View file

@ -14,6 +14,8 @@ way to update this template, but currently, we follow a pattern:
## Upcoming version 2019-XX-XX
- [add] Keyword search/filter added to SearchPage component.
[#1129](https://github.com/sharetribe/flex-template-web/pull/1129)
- [fix] temporarily remove audit CI job.
[#1136](https://github.com/sharetribe/flex-template-web/pull/1136)
- [change] Update outdated dependencies. This includes updating lodash to fix the security issue.

View file

@ -1,5 +1,5 @@
import React, { Component } from 'react';
import { func, object, shape, string } from 'prop-types';
import { bool, func, object, shape, string } from 'prop-types';
import { Field } from 'react-final-form';
import classNames from 'classnames';
import { ValidationError, ExpandingTextarea } from '../../components';
@ -22,6 +22,8 @@ class FieldTextInputComponent extends Component {
input,
meta,
onUnmount,
isUncontrolled,
inputRef,
...rest
} = this.props;
/* eslint-enable no-unused-vars */
@ -41,6 +43,11 @@ class FieldTextInputComponent extends Component {
const fieldMeta = { touched: hasError, error: errorText };
// Uncontrolled input uses defaultValue instead of value.
const { value: defaultValue, ...inputWithoutValue } = input;
// Use inputRef if it is passed as prop.
const refMaybe = inputRef ? { ref: inputRef } : {};
const inputClasses =
inputRootClass ||
classNames(css.input, {
@ -48,9 +55,20 @@ class FieldTextInputComponent extends Component {
[css.inputError]: hasError,
[css.textarea]: isTextarea,
});
const maxLength = CONTENT_MAX_LENGTH;
const inputProps = isTextarea
? { className: inputClasses, id, rows: 1, maxLength: CONTENT_MAX_LENGTH, ...input, ...rest }
: { className: inputClasses, id, type, ...input, ...rest };
? { className: inputClasses, id, rows: 1, maxLength, ...refMaybe, ...input, ...rest }
: isUncontrolled
? {
className: inputClasses,
id,
type,
defaultValue,
...refMaybe,
...inputWithoutValue,
...rest,
}
: { className: inputClasses, id, type, ...refMaybe, ...input, ...rest };
const classes = classNames(rootClassName || css.root, className);
return (
@ -71,6 +89,8 @@ FieldTextInputComponent.defaultProps = {
customErrorText: null,
id: null,
label: null,
isUncontrolled: false,
inputRef: null,
};
FieldTextInputComponent.propTypes = {
@ -92,6 +112,12 @@ FieldTextInputComponent.propTypes = {
// Either 'textarea' or something that is passed to the input element
type: string.isRequired,
// Uncontrolled input uses defaultValue prop, but doesn't pass value from form to the field.
// https://reactjs.org/docs/uncontrolled-components.html#default-values
isUncontrolled: bool,
// a ref object passed for input element.
inputRef: object,
// Generated by final-form's Field component
input: shape({
onChange: func.isRequired,

View file

@ -41,6 +41,11 @@
border: 1px solid var(--marketplaceColorDark);
}
}
.labelEllipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.popup {
/* By default hide the content */

View file

@ -103,6 +103,7 @@ class FilterPopup extends Component {
popupClassName,
id,
label,
labelMaxWidth,
isSelected,
children,
initialValues,
@ -114,6 +115,8 @@ class FilterPopup extends Component {
const popupClasses = classNames(css.popup, { [css.isOpen]: this.state.isOpen });
const popupSizeClasses = popupClassName || css.popupSize;
const labelStyles = isSelected ? css.labelSelected : css.label;
const labelMaxWidthMaybe = labelMaxWidth ? { maxWidth: `${labelMaxWidth}px` } : {};
const labelMaxWidthStyles = labelMaxWidth ? css.labelEllipsis : null;
const contentStyle = this.positionStyleForContent();
return (
@ -125,7 +128,11 @@ class FilterPopup extends Component {
this.filter = node;
}}
>
<button className={labelStyles} onClick={() => this.toggleOpen()}>
<button
className={classNames(labelStyles, labelMaxWidthStyles)}
style={labelMaxWidthMaybe}
onClick={() => this.toggleOpen()}
>
{label}
</button>
<div
@ -167,6 +174,7 @@ FilterPopup.defaultProps = {
contentPlacementOffset: 0,
liveEdit: false,
label: null,
labelMaxWidth: null,
};
FilterPopup.propTypes = {
@ -180,6 +188,7 @@ FilterPopup.propTypes = {
keepDirtyOnReinitialize: bool,
contentPlacementOffset: number,
label: string.isRequired,
labelMaxWidth: number,
isSelected: bool.isRequired,
children: node.isRequired,

View file

@ -0,0 +1,65 @@
@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);
}
}
.labelText {
display: inline-block;
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.field {
@apply --marketplaceH4FontStyles;
margin: 0;
padding: 1px 0 13px 0;
border: none;
}
.fieldPlain {
@apply --marketplaceH4FontStyles;
margin: 0;
padding: 16px 0 30px 20px;
border: none;
}

View file

@ -0,0 +1,69 @@
import React from 'react';
import { withRouter } from 'react-router-dom';
import KeywordFilter from './KeywordFilter';
import { stringify, parse } from '../../util/urlHelpers';
const URL_PARAM = 'keywords';
const handleSubmit = (urlParam, values, history) => {
console.log('Submitting values', values);
const queryParams = values ? `?${stringify({ [urlParam]: values })}` : '';
history.push(`${window.location.pathname}${queryParams}`);
};
const KeywordFilterPopup = withRouter(props => {
const { history, location } = props;
const params = parse(location.search);
const keyword = params[URL_PARAM];
const initialValues = !!keyword ? keyword : '';
return (
<KeywordFilter
id="KeywordFilterPopupExample"
name="keyword"
urlParam={URL_PARAM}
label="Keyword"
onSubmit={(urlParam, values) => handleSubmit(urlParam, values, history)}
showAsPopup={true}
liveEdit={false}
initialValues={initialValues}
contentPlacementOffset={-14}
/>
);
});
export const KeywordFilterPopupExample = {
component: KeywordFilterPopup,
props: {},
group: 'filters',
};
const KeywordFilterPlain = withRouter(props => {
const { history, location } = props;
const params = parse(location.search);
const keyword = params[URL_PARAM];
const initialValues = !!keyword ? keyword : '';
return (
<KeywordFilter
id="KeywordFilterPlainExample"
name="keyword"
urlParam={URL_PARAM}
label="Keyword"
onSubmit={(urlParam, values) => {
handleSubmit(urlParam, values, history);
}}
showAsPopup={false}
liveEdit={true}
initialValues={initialValues}
/>
);
});
export const KeywordFilterPlainExample = {
component: KeywordFilterPlain,
props: {},
group: 'filters',
};

View file

@ -0,0 +1,214 @@
import React, { Component } from 'react';
import { func, number, string } from 'prop-types';
import classNames from 'classnames';
import { injectIntl, intlShape } from 'react-intl';
import debounce from 'lodash/debounce';
import { FieldTextInput } from '../../components';
import { FilterPopup, FilterPlain } from '../../components';
import css from './KeywordFilter.css';
// When user types, we wait for new keystrokes a while before searching new content
const DEBOUNCE_WAIT_TIME = 600;
// Short search queries (e.g. 2 letters) have a longer timeout before search is made
const TIMEOUT_FOR_SHORT_QUERIES = 2000;
class KeywordFilter extends Component {
constructor(props) {
super(props);
this.filter = null;
this.filterContent = null;
this.shortKeywordTimeout = null;
this.mobileInputRef = React.createRef();
this.positionStyleForContent = this.positionStyleForContent.bind(this);
}
componentWillUnmount() {
window.clearTimeout(this.shortKeywordTimeout);
}
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,
name,
label,
initialValues,
contentPlacementOffset,
onSubmit,
urlParam,
intl,
showAsPopup,
...rest
} = this.props;
const classes = classNames(rootClassName || css.root, className);
const hasInitialValues = !!initialValues && initialValues.length > 0;
const labelForPopup = hasInitialValues
? intl.formatMessage({ id: 'KeywordFilter.labelSelected' }, { labelText: initialValues })
: label;
const labelForPlain = hasInitialValues
? intl.formatMessage(
{ id: 'KeywordFilterPlainForm.labelSelected' },
{ labelText: initialValues }
)
: label;
const filterText = intl.formatMessage({ id: 'KeywordFilter.filterText' });
const placeholder = intl.formatMessage({ id: 'KeywordFilter.placeholder' });
const contentStyle = this.positionStyleForContent();
// pass the initial values with the name key so that
// they can be passed to the correct field
const namedInitialValues = { [name]: initialValues };
const handleSubmit = (urlParam, values) => {
const usedValue = values ? values[name] : values;
onSubmit(urlParam, usedValue);
};
const debouncedSubmit = debounce(handleSubmit, DEBOUNCE_WAIT_TIME, {
leading: false,
trailing: true,
});
// Use timeout for shart queries and debounce for queries with any length
const handleChangeWithDebounce = (urlParam, values) => {
// handleSubmit gets urlParam and values as params.
// If this field ("keyword") is short, create timeout
const hasKeywordValue = values && values[name];
const keywordValue = hasKeywordValue ? values && values[name] : '';
if (urlParam && (!hasKeywordValue || (hasKeywordValue && keywordValue.length >= 3))) {
if (this.shortKeywordTimeout) {
window.clearTimeout(this.shortKeywordTimeout);
}
return debouncedSubmit(urlParam, values);
} else {
this.shortKeywordTimeout = window.setTimeout(() => {
// if mobileInputRef exists, use the most up-to-date value from there
return this.mobileInputRef && this.mobileInputRef.current
? handleSubmit(urlParam, { ...values, [name]: this.mobileInputRef.current.value })
: handleSubmit(urlParam, values);
}, TIMEOUT_FOR_SHORT_QUERIES);
}
};
// Uncontrolled input needs to be cleared through the reference to DOM element.
const handleClear = () => {
if (this.mobileInputRef && this.mobileInputRef.current) {
this.mobileInputRef.current.value = '';
}
};
return showAsPopup ? (
<FilterPopup
className={classes}
rootClassName={rootClassName}
popupClassName={css.popupSize}
name={name}
label={labelForPopup}
isSelected={hasInitialValues}
id={`${id}.popup`}
showAsPopup
labelMaxWidth={250}
contentPlacementOffset={contentPlacementOffset}
onSubmit={handleSubmit}
initialValues={namedInitialValues}
urlParam={urlParam}
keepDirtyOnReinitialize
{...rest}
>
<FieldTextInput
className={css.field}
name={name}
id={`${id}-input`}
type="text"
label={filterText}
placeholder={placeholder}
autoComplete="off"
/>
</FilterPopup>
) : (
<FilterPlain
className={className}
rootClassName={rootClassName}
label={labelForPlain}
isSelected={hasInitialValues}
id={`${id}.plain`}
liveEdit
contentPlacementOffset={contentStyle}
onSubmit={handleChangeWithDebounce}
onClear={handleClear}
initialValues={namedInitialValues}
urlParam={urlParam}
{...rest}
>
<fieldset className={css.fieldPlain}>
<label>{filterText}</label>
<FieldTextInput
name={name}
id={`${id}-input`}
isUncontrolled
inputRef={this.mobileInputRef}
type="text"
placeholder={placeholder}
autoComplete="off"
/>
</fieldset>
</FilterPlain>
);
}
}
KeywordFilter.defaultProps = {
rootClassName: null,
className: null,
initialValues: null,
contentPlacementOffset: 0,
};
KeywordFilter.propTypes = {
rootClassName: string,
className: string,
id: string.isRequired,
name: string.isRequired,
urlParam: string.isRequired,
label: string.isRequired,
onSubmit: func.isRequired,
initialValues: string,
contentPlacementOffset: number,
// form injectIntl
intl: intlShape.isRequired,
};
export default injectIntl(KeywordFilter);

View file

@ -11,6 +11,7 @@ import {
SelectSingleFilter,
SelectMultipleFilter,
PriceFilter,
KeywordFilter,
} from '../../components';
import routeConfiguration from '../../routeConfiguration';
import { parseDateFromISO8601, stringifyDateToISO8601 } from '../../util/dates';
@ -70,6 +71,7 @@ const SearchFiltersComponent = props => {
amenitiesFilter,
priceFilter,
dateRangeFilter,
keywordFilter,
isSearchFiltersPanelOpen,
toggleSearchFiltersPanel,
searchFiltersPanelSelectedCount,
@ -88,6 +90,10 @@ const SearchFiltersComponent = props => {
id: 'SearchFilters.amenitiesLabel',
});
const keywordLabel = intl.formatMessage({
id: 'SearchFilters.keywordLabel',
});
const initialAmenities = amenitiesFilter
? initialValues(urlQueryParams, amenitiesFilter.paramName)
: null;
@ -104,6 +110,10 @@ const SearchFiltersComponent = props => {
? initialDateRangeValue(urlQueryParams, dateRangeFilter.paramName)
: null;
const initialKeyword = keywordFilter
? initialValue(urlQueryParams, keywordFilter.paramName)
: null;
const handleSelectOptions = (urlParam, options) => {
const queryParams =
options && options.length > 0
@ -147,6 +157,14 @@ const SearchFiltersComponent = props => {
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
};
const handleKeyword = (urlParam, values) => {
const queryParams = values
? { ...urlQueryParams, [urlParam]: values }
: omit(urlQueryParams, urlParam);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
};
const categoryFilterElement = categoryFilter ? (
<SelectSingleFilter
urlParam={categoryFilter.paramName}
@ -197,6 +215,20 @@ const SearchFiltersComponent = props => {
/>
) : null;
const keywordFilterElement =
keywordFilter && keywordFilter.config.active ? (
<KeywordFilter
id={'SearchFilters.keywordFilter'}
name="keyword"
urlParam={keywordFilter.paramName}
label={keywordLabel}
onSubmit={handleKeyword}
showAsPopup
initialValues={initialKeyword}
contentPlacementOffset={FILTER_DROPDOWN_OFFSET}
/>
) : null;
const toggleSearchFiltersPanelButtonClasses =
isSearchFiltersPanelOpen || searchFiltersPanelSelectedCount > 0
? css.searchFiltersPanelOpen
@ -221,6 +253,7 @@ const SearchFiltersComponent = props => {
{amenitiesFilterElement}
{priceFilterElement}
{dateRangeFilterElement}
{keywordFilterElement}
{toggleSearchFiltersPanelButton}
</div>

View file

@ -11,6 +11,7 @@ import { createResourceLocatorString } from '../../util/routes';
import {
ModalInMobile,
Button,
KeywordFilter,
PriceFilter,
SelectSingleFilter,
SelectMultipleFilter,
@ -34,6 +35,7 @@ class SearchFiltersMobileComponent extends Component {
this.handleSelectMultiple = this.handleSelectMultiple.bind(this);
this.handlePrice = this.handlePrice.bind(this);
this.handleDateRange = this.handleDateRange.bind(this);
this.handleKeyword = this.handleKeyword.bind(this);
this.initialValue = this.initialValue.bind(this);
this.initialValues = this.initialValues.bind(this);
this.initialPriceRangeValue = this.initialPriceRangeValue.bind(this);
@ -118,6 +120,15 @@ class SearchFiltersMobileComponent extends Component {
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
}
handleKeyword(urlParam, keywords) {
const { urlQueryParams, history } = this.props;
const queryParams = urlParam
? { ...urlQueryParams, [urlParam]: keywords }
: omit(urlQueryParams, urlParam);
history.push(createResourceLocatorString('SearchPage', routeConfiguration(), {}, queryParams));
}
// Reset all filter query parameters
resetAll(e) {
const { urlQueryParams, history, filterParamNames } = this.props;
@ -185,6 +196,7 @@ class SearchFiltersMobileComponent extends Component {
amenitiesFilter,
priceFilter,
dateRangeFilter,
keywordFilter,
intl,
} = this.props;
@ -258,7 +270,7 @@ class SearchFiltersMobileComponent extends Component {
const dateRangeFilterElement =
dateRangeFilter && dateRangeFilter.config.active ? (
<BookingDateRangeFilter
id="SearchFilters.dateRangeFilter"
id="SearchFiltersMobile.dateRangeFilter"
urlParam={dateRangeFilter.paramName}
onSubmit={this.handleDateRange}
liveEdit
@ -267,6 +279,24 @@ class SearchFiltersMobileComponent extends Component {
/>
) : null;
const initialKeyword = this.initialValue(keywordFilter.paramName);
const keywordLabel = intl.formatMessage({
id: 'SearchFiltersMobile.keywordLabel',
});
const keywordFilterElement =
keywordFilter && keywordFilter.config.active ? (
<KeywordFilter
id={'SearchFiltersMobile.keywordFilter'}
name="keyword"
urlParam={keywordFilter.paramName}
label={keywordLabel}
onSubmit={this.handleKeyword}
liveEdit
showAsPopup={false}
initialValues={initialKeyword}
/>
) : null;
return (
<div className={classes}>
<div className={css.searchResultSummary}>
@ -299,6 +329,7 @@ class SearchFiltersMobileComponent extends Component {
</div>
{this.state.isFiltersOpenOnMobile ? (
<div className={css.filtersWrapper}>
{keywordFilterElement}
{categoryFilterElement}
{amenitiesFilterElement}
{priceFilterElement}

View file

@ -119,6 +119,7 @@ export { default as BookingPanel } from './BookingPanel/BookingPanel';
export { default as Discussion } from './Discussion/Discussion';
export { default as FilterPlain } from './FilterPlain/FilterPlain';
export { default as FilterPopup } from './FilterPopup/FilterPopup';
export { default as KeywordFilter } from './KeywordFilter/KeywordFilter';
export { default as ListingCard } from './ListingCard/ListingCard';
export { default as ManageListingCard } from './ManageListingCard/ManageListingCard';
export { default as Map } from './Map/Map';

View file

@ -22,6 +22,8 @@ const i18n = {
// Should search results be ordered by distance to origin.
// NOTE: If this is set to true add parameter 'origin' to every location in default-location-searches.js
// Without the 'origin' parameter, search will not work correctly
// NOTE: Keyword search and ordering search results by distance can't be used at the same time. You can turn keyword
// search off by changing the keywordFilterConfig parameter active to false in marketplace-custom-config.js
const sortSearchByDistance = false;
// API supports custom processes to be used in booking process.

View file

@ -13,11 +13,12 @@ import routeConfiguration from '../../routeConfiguration';
* @param {Object} paramValue Search parameter value
* @param {Object} filters Filters configuration
*/
export const validURLParamForExtendedData = (paramName, paramValue, filters) => {
export const validURLParamForExtendedData = (paramName, paramValueRaw, filters) => {
const filtersArray = Object.values(filters);
// resolve configuration for this filter
const filterConfig = filtersArray.find(f => f.paramName === paramName);
const paramValue = paramValueRaw.toString();
const valueArray = paramValue ? paramValue.split(',') : [];
if (filterConfig && valueArray.length > 0) {

View file

@ -52,7 +52,13 @@ export class SearchPageComponent extends Component {
}
filters() {
const { categories, amenities, priceFilterConfig, dateRangeFilterConfig } = this.props;
const {
categories,
amenities,
priceFilterConfig,
dateRangeFilterConfig,
keywordFilterConfig,
} = this.props;
// Note: "category" and "amenities" filters are not actually filtering anything by default.
// Currently, if you want to use them, we need to manually configure them to be available
@ -76,6 +82,10 @@ export class SearchPageComponent extends Component {
paramName: 'dates',
config: dateRangeFilterConfig,
},
keywordFilter: {
paramName: 'keywords',
config: keywordFilterConfig,
},
};
}
@ -220,6 +230,7 @@ export class SearchPageComponent extends Component {
amenitiesFilter: filters.amenitiesFilter,
priceFilter: filters.priceFilter,
dateRangeFilter: filters.dateRangeFilter,
keywordFilter: filters.keywordFilter,
}}
/>
<ModalInMobile
@ -267,6 +278,7 @@ SearchPageComponent.defaultProps = {
amenities: config.custom.amenities,
priceFilterConfig: config.custom.priceFilterConfig,
dateRangeFilterConfig: config.custom.dateRangeFilterConfig,
keywordFilterConfig: config.custom.keywordFilterConfig,
activeListingId: null,
};

View file

@ -72,6 +72,12 @@ exports[`SearchPageComponent matches snapshot 1`] = `
},
"paramName": "dates",
},
"keywordFilter": Object {
"config": Object {
"active": true,
},
"paramName": "keywords",
},
"priceFilter": Object {
"config": Object {
"max": 1000,

View file

@ -42,6 +42,7 @@ import * as IconSocialMediaInstagram from './components/IconSocialMediaInstagram
import * as IconSocialMediaTwitter from './components/IconSocialMediaTwitter/IconSocialMediaTwitter.example';
import * as IconSpinner from './components/IconSpinner/IconSpinner.example';
import * as ImageCarousel from './components/ImageCarousel/ImageCarousel.example';
import * as KeywordFilter from './components/KeywordFilter/KeywordFilter.example';
import * as ListingCard from './components/ListingCard/ListingCard.example';
import * as LocationAutocompleteInput from './components/LocationAutocompleteInput/LocationAutocompleteInput.example';
import * as ManageListingCard from './components/ManageListingCard/ManageListingCard.example';
@ -148,6 +149,7 @@ export {
IconSocialMediaTwitter,
IconSpinner,
ImageCarousel,
KeywordFilter,
ListingCard,
LocationAutocompleteInput,
LoginForm,

View file

@ -56,3 +56,11 @@ export const priceFilterConfig = {
export const dateRangeFilterConfig = {
active: true,
};
// Activate keyword filter on search page
// NOTE: If you are ordering search results by distance the keyword search can't be used at the same time.
// You can turn off ordering by distance in config.js file
export const keywordFilterConfig = {
active: true,
};

View file

@ -310,6 +310,11 @@
"InboxPage.statePendingPayment": "Pending payment",
"InboxPage.stateRequested": "Requested",
"InboxPage.title": "Inbox",
"KeywordFilter.filterText": "Filter results by",
"KeywordFilter.labelSelected": "\"{labelText}\"",
"KeywordFilter.placeholder": "Enter a keyword…",
"KeywordFilterPlainForm.labelSelected": "Keyword",
"KeywordFilterPlainForm.placeholder": "Enter a keyword…",
"LandingPage.schemaDescription": "Book a sauna using Saunatime or earn some income by sharing your sauna",
"LandingPage.schemaTitle": "Book saunas everywhere | {siteTitle}",
"ListingCard.hostedBy": "Hosted by {authorName}.",
@ -682,6 +687,7 @@
"SearchFilters.categoryLabel": "Category",
"SearchFilters.filtersButtonLabel": "Filters",
"SearchFilters.foundResults": "{count, number} {count, plural, one {result} other {results}}",
"SearchFilters.keywordLabel": "Keyword",
"SearchFilters.loadingResults": "Loading search results…",
"SearchFilters.loadingResultsMobile": "Loading…",
"SearchFilters.moreFiltersButton": "{count, plural, =0 {More filters} other {More filters • #}}",
@ -692,6 +698,7 @@
"SearchFiltersMobile.cancel": "CANCEL",
"SearchFiltersMobile.categoryLabel": "Category",
"SearchFiltersMobile.heading": "Filter saunas",
"SearchFiltersMobile.keywordLabel": "Keyword",
"SearchFiltersMobile.resetAll": "Reset all",
"SearchFiltersMobile.showListings": "Show {count, number} {count, plural, one {sauna} other {saunas}}",
"SearchFiltersPanel.apply": "Apply",