Merged in feat/SW-342-filtering-and-sorting-mobile (pull request #919)

Feat/SW-342 filtering and sorting mobile

* feat(SW-342): add sort and filter on mobile

* Use zustand for state management

* Add count and translations

* Clear filters

* Small fixes

* Fixes


Approved-by: Pontus Dreij
This commit is contained in:
Niclas Edenvin
2024-11-18 14:02:32 +00:00
committed by Joakim Jäderberg
parent 3c181959c2
commit 5fb70866ea
24 changed files with 434 additions and 113 deletions

View File

@@ -2,7 +2,7 @@
import { useSearchParams } from "next/navigation"
import { useMemo } from "react"
import Title from "@/components/TempDesignSystem/Text/Title"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import HotelCard from "../HotelCard"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
@@ -22,6 +22,8 @@ export default function HotelCardListing({
onHotelCardHover,
}: HotelCardListingProps) {
const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const setResultCount = useHotelFilterStore((state) => state.setResultCount)
const sortBy = useMemo(
() => searchParams.get("sort") ?? DEFAULT_SORT,
@@ -57,17 +59,22 @@ export default function HotelCardListing({
}, [hotelData, sortBy])
const hotels = useMemo(() => {
const appliedFilters = searchParams.get("filters")?.split(",")
if (!appliedFilters || appliedFilters.length === 0) return sortedHotels
if (activeFilters.length === 0) {
setResultCount(sortedHotels.length)
return sortedHotels
}
return sortedHotels.filter((hotel) =>
appliedFilters.every((appliedFilterId) =>
const filteredHotels = sortedHotels.filter((hotel) =>
activeFilters.every((appliedFilterId) =>
hotel.hotelData.detailedFacilities.some(
(facility) => facility.id.toString() === appliedFilterId
)
)
)
}, [searchParams, sortedHotels])
setResultCount(filteredHotels.length)
return filteredHotels
}, [activeFilters, sortedHotels, setResultCount])
return (
<section className={styles.hotelCards}>

View File

@@ -0,0 +1,99 @@
@keyframes modal-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes modal-slide-up {
from {
bottom: -100%;
}
to {
bottom: 0;
}
}
.overlay {
align-items: center;
background: rgba(0, 0, 0, 0.5);
display: flex;
height: var(--visual-viewport-height);
justify-content: center;
left: 0;
position: fixed;
top: 0;
width: 100vw;
z-index: 100;
&[data-entering] {
animation: modal-fade 200ms;
}
&[data-exiting] {
animation: modal-fade 150ms reverse ease-in;
}
}
.modal {
position: absolute;
left: 0;
bottom: 0;
height: calc(100dvh - 20px);
background-color: var(--Base-Surface-Primary-light-Normal);
border-radius: var(--Corner-radius-Medium);
box-shadow: 0px 4px 24px 0px rgba(38, 32, 30, 0.08);
width: 100%;
&[data-entering] {
animation: modal-slide-up 200ms;
}
&[data-existing] {
animation: modal-slide-up 200ms reverse;
}
}
.content {
flex-direction: column;
gap: var(--Spacing-x3);
display: flex;
height: 100%;
}
.sorter {
padding: var(--Spacing-x2);
flex: 0 0 auto;
}
.filters {
padding: var(--Spacing-x2);
flex: 1 1 auto;
overflow-y: auto;
}
.header {
text-align: right;
padding: var(--Spacing-x-one-and-half);
flex: 0 0 auto;
}
.close {
background: none;
border: none;
cursor: pointer;
justify-self: flex-end;
padding: 0;
}
.footer {
display: flex;
flex-direction: column-reverse;
gap: var(--Spacing-x1);
padding: var(--Spacing-x2);
flex: 0 0 auto;
border-top: 1px solid var(--Base-Border-Subtle);
}

View File

@@ -0,0 +1,87 @@
"use client"
import {
Dialog as AriaDialog,
DialogTrigger,
Modal,
ModalOverlay,
} from "react-aria-components"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import { CloseLargeIcon, FilterIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import HotelFilter from "../HotelFilter"
import HotelSorter from "../HotelSorter"
import styles from "./filterAndSortModal.module.css"
import type { FilterAndSortModalProps } from "@/types/components/hotelReservation/selectHotel/filterAndSortModal"
export default function FilterAndSortModal({
filters,
}: FilterAndSortModalProps) {
const intl = useIntl()
const resultCount = useHotelFilterStore((state) => state.resultCount)
const setFilters = useHotelFilterStore((state) => state.setFilters)
return (
<>
<DialogTrigger>
<Button intent="secondary" size="small" theme="base">
<FilterIcon color="burgundy" />
{intl.formatMessage({ id: "Filter and sort" })}
</Button>
<ModalOverlay className={styles.overlay} isDismissable>
<Modal className={styles.modal}>
<AriaDialog role="alertdialog" className={styles.content}>
{({ close }) => (
<>
<header className={styles.header}>
<button
onClick={close}
type="button"
className={styles.close}
>
<CloseLargeIcon />
</button>
</header>
<div className={styles.sorter}>
<HotelSorter />
</div>
<div className={styles.filters}>
<HotelFilter filters={filters} />
</div>
<footer className={styles.footer}>
<Button
intent="primary"
size="medium"
theme="base"
onClick={close}
>
{intl.formatMessage(
{ id: "See results" },
{ count: resultCount }
)}
</Button>
<Button
onClick={() => setFilters([])}
intent="text"
size="medium"
theme="base"
>
{intl.formatMessage({ id: "Clear all filters" })}
</Button>
</footer>
</>
)}
</AriaDialog>
</Modal>
</ModalOverlay>
</DialogTrigger>
</>
)
}

View File

@@ -0,0 +1,29 @@
.container {
display: flex;
flex-direction: column;
color: var(--text-color);
}
.container[data-selected] .checkbox {
border: none;
background: var(--UI-Input-Controls-Fill-Selected);
}
.checkboxContainer {
display: flex;
align-items: center;
gap: var(--Spacing-x-one-and-half);
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
forced-color-adjust: none;
}

View File

@@ -0,0 +1,35 @@
"use client"
import { Checkbox as AriaCheckbox } from "react-aria-components"
import CheckIcon from "@/components/Icons/Check"
import styles from "./filterCheckbox.module.css"
import type { FilterCheckboxProps } from "@/types/components/hotelReservation/selectHotel/filterCheckbox"
export default function FilterCheckbox({
isSelected,
name,
id,
onChange,
}: FilterCheckboxProps) {
return (
<AriaCheckbox
className={styles.container}
isSelected={isSelected}
onChange={() => onChange(id)}
>
{({ isSelected }) => (
<>
<span className={styles.checkboxContainer}>
<span className={styles.checkbox}>
{isSelected && <CheckIcon color="white" />}
</span>
{name}
</span>
</>
)}
</AriaCheckbox>
)
}

View File

@@ -1,6 +1,5 @@
.container {
min-width: 272px;
display: none;
}
.container form {
@@ -39,9 +38,3 @@
height: 1.25rem;
margin: 0;
}
@media (min-width: 768px) {
.container {
display: block;
}
}

View File

@@ -1,37 +1,42 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import { FormProvider, useForm } from "react-hook-form"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import Checkbox from "@/components/TempDesignSystem/Form/Checkbox"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import FilterCheckbox from "./FilterCheckbox"
import styles from "./hotelFilter.module.css"
import type { HotelFiltersProps } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function HotelFilter({ filters }: HotelFiltersProps) {
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
const toggleFilter = useHotelFilterStore((state) => state.toggleFilter)
const setFilters = useHotelFilterStore((state) => state.setFilters)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const methods = useForm<Record<string, boolean | undefined>>({
defaultValues: searchParams
?.get("filters")
?.split(",")
.reduce((acc, curr) => ({ ...acc, [curr]: true }), {}),
})
const { watch, handleSubmit, getValues, register } = methods
// Initialize the filters from the URL
useEffect(() => {
const filtersFromUrl = searchParams.get("filters")
if (filtersFromUrl) {
setFilters(filtersFromUrl.split(","))
} else {
setFilters([])
}
}, [searchParams, setFilters])
const submitFilter = useCallback(() => {
// Update the URL when the filters changes
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams)
const values = Object.entries(getValues())
.filter(([_, value]) => !!value)
.map(([key, _]) => key)
.join(",")
const values = activeFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
@@ -46,49 +51,51 @@ export default function HotelFilter({ filters }: HotelFiltersProps) {
`${pathname}?${newSearchParams.toString()}`
)
}
}, [getValues, pathname, searchParams])
useEffect(() => {
const subscription = watch(() => handleSubmit(submitFilter)())
return () => subscription.unsubscribe()
}, [handleSubmit, watch, submitFilter])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilters])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
return (
<aside className={styles.container}>
<FormProvider {...methods}>
<form onSubmit={handleSubmit(submitFilter)}>
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel facilities" })}
</Subtitle>
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
</li>
))}
</ul>
</div>
<aside className={`${styles.container} ${className}`}>
<Title as="h4">{intl.formatMessage({ id: "Filter by" })}</Title>
<div className={styles.facilities}>
<Subtitle>{intl.formatMessage({ id: "Hotel facilities" })}</Subtitle>
<ul>
{filters.facilityFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={
!!activeFilters.find((f) => f === filter.id.toString())
}
/>
</li>
))}
</ul>
</div>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({ id: "Hotel surroundings" })}
</Subtitle>
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<Checkbox name={filter.id.toString()}>{filter.name}</Checkbox>
</li>
))}
</ul>
</div>
</form>
</FormProvider>
<div className={styles.facilities}>
<Subtitle>{intl.formatMessage({ id: "Hotel surroundings" })}</Subtitle>
<ul>
{filters.surroundingsFilters.map((filter) => (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={
!!activeFilters.find((f) => f === filter.id.toString())
}
/>
</li>
))}
</ul>
</div>
</aside>
)
}

View File

@@ -1,9 +0,0 @@
.container {
width: 339px;
}
@media (max-width: 768px) {
.container {
display: none;
}
}

View File

@@ -6,16 +6,15 @@ import { useIntl } from "react-intl"
import Select from "@/components/TempDesignSystem/Select"
import styles from "./hotelSorter.module.css"
import {
type HotelSorterProps,
type SortItem,
SortOrder,
} from "@/types/components/hotelReservation/selectHotel/hotelSorter"
export const DEFAULT_SORT = SortOrder.Distance
export default function HotelSorter() {
export default function HotelSorter({ discreet }: HotelSorterProps) {
const searchParams = useSearchParams()
const pathname = usePathname()
const intl = useIntl()
@@ -52,16 +51,14 @@ export default function HotelSorter() {
]
return (
<div className={styles.container}>
<Select
items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
discreet
onSelect={onSelect}
/>
</div>
<Select
items={sortItems}
defaultSelectedKey={searchParams.get("sort") ?? DEFAULT_SORT}
label={intl.formatMessage({ id: "Sort by" })}
name="sort"
showRadioButton
discreet={discreet}
onSelect={onSelect}
/>
)
}

View File

@@ -4,26 +4,28 @@ import { useIntl } from "react-intl"
import { selectHotelMap } from "@/constants/routes/hotelReservation"
import { FilterIcon, MapIcon } from "@/components/Icons"
import { MapIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import styles from "./mobileMapButtonContainer.module.css"
export default function MobileMapButtonContainer({ city }: { city: string }) {
import { CategorizedFilters } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function MobileMapButtonContainer({
filters,
}: {
filters: CategorizedFilters
}) {
const intl = useIntl()
const lang = useLang()
return (
<div className={styles.buttonContainer}>
<Button
asChild
variant="icon"
intent="secondary"
size="small"
className={styles.button}
>
<Button asChild variant="icon" intent="secondary" size="small">
<Link
href={`${selectHotelMap[lang]}`}
keepSearchParams
@@ -33,16 +35,7 @@ export default function MobileMapButtonContainer({ city }: { city: string }) {
{intl.formatMessage({ id: "See on map" })}
</Link>
</Button>
{/* TODO: Add filter toggle */}
<Button
variant="icon"
intent="secondary"
size="small"
className={styles.button}
>
<FilterIcon color="burgundy" />
{intl.formatMessage({ id: "Filter and sort" })}
</Button>
<FilterAndSortModal filters={filters} />
</div>
)
}

View File

@@ -4,8 +4,8 @@
margin-bottom: var(--Spacing-x3);
}
.button {
flex: 1;
.buttonContainer > * {
flex: 1 1 50%;
}
@media (min-width: 768px) {