Merged in fix/SW-2841-filter-popup-closing-on-select (pull request #2289)

Fix/SW-2841 filter popup closing on select

* fix(SW-2841): refactored so that filter modal is not closed when selecting filters

* fix(SW-2841): rename component

* fix: review feedback

* fix: move font-family

* fix: change init value of filteredHotelIds

* fix

* fix: add Typography tag


Approved-by: Michael Zetterberg
Approved-by: Christian Andolf
This commit is contained in:
Tobias Johansson
2025-06-13 07:41:31 +00:00
parent 7be6c5dfb5
commit e645b15c6e
18 changed files with 339 additions and 175 deletions

View File

@@ -28,6 +28,7 @@ import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
export default function HotelCardListing({
hotelData,
unfilteredHotelCount,
type = HotelCardListingTypeEnum.PageListing,
}: HotelCardListingProps) {
const { data: session } = useSession()
@@ -104,8 +105,8 @@ export default function HotelCardListing({
}, [activeHotel, type])
useEffect(() => {
setResultCount(hotels.length)
}, [hotels, setResultCount])
setResultCount(hotels.length, unfilteredHotelCount)
}, [hotels, setResultCount, unfilteredHotelCount])
function isHotelActiveInMapView(hotelName: string): boolean {
return (

View File

@@ -4,7 +4,7 @@ import {
usePathname,
useSearchParams,
} from "next/dist/client/components/navigation"
import { useCallback, useState } from "react"
import { useCallback, useEffect, useState } from "react"
import {
Dialog as AriaDialog,
DialogTrigger,
@@ -24,8 +24,8 @@ import Footnote from "@/components/TempDesignSystem/Text/Footnote"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
import HotelFilter from "../HotelFilter"
import { DEFAULT_SORT } from "../HotelSorter"
import { DEFAULT_SORT } from "../../HotelSorter"
import FilterContent from "../FilterContent"
import styles from "./filterAndSortModal.module.css"
@@ -40,14 +40,32 @@ export default function FilterAndSortModal({
setShowSkeleton,
}: FilterAndSortModalProps) {
const intl = useIntl()
useInitializeFiltersFromUrl()
const searchParams = useSearchParams()
const pathname = usePathname()
const resultCount = useHotelFilterStore((state) => state.resultCount)
const setFilters = useHotelFilterStore((state) => state.setFilters)
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const { resultCount, setFilters, activeFilters, unfilteredResultCount } =
useHotelFilterStore((state) => ({
resultCount: state.resultCount,
setFilters: state.setFilters,
activeFilters: state.activeFilters,
unfilteredResultCount: state.unfilteredResultCount,
}))
const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT)
const [selectedFilters, setSelectedFilters] =
useState<string[]>(activeFilters)
const [filteredCount, setFilteredCount] = useState(resultCount)
useEffect(() => {
if (activeFilters.length) {
setSelectedFilters(activeFilters)
}
}, [activeFilters])
const sortItems: SortItem[] = [
{
label: intl.formatMessage({
@@ -81,14 +99,21 @@ export default function FilterAndSortModal({
const handleApplyFiltersAndSorting = useCallback(
(close: () => void) => {
setFilters(selectedFilters)
if (setShowSkeleton) {
setShowSkeleton(true)
}
if (sort === searchParams.get("sort")) {
close()
}
const newSearchParams = new URLSearchParams(searchParams)
const values = selectedFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
} else {
newSearchParams.set("filters", values)
}
newSearchParams.set("sort", sort)
window.history.replaceState(
@@ -103,7 +128,7 @@ export default function FilterAndSortModal({
}, 500)
}
},
[pathname, searchParams, sort, setShowSkeleton]
[pathname, searchParams, sort, setShowSkeleton, selectedFilters, setFilters]
)
return (
@@ -159,7 +184,19 @@ export default function FilterAndSortModal({
<Divider color="subtle" />
</div>
<div className={styles.filters}>
<HotelFilter filters={filters} />
<FilterContent
filters={filters}
activeFilters={selectedFilters}
onChange={(id) => {
const isSelected = selectedFilters.includes(id)
setSelectedFilters((prev) =>
isSelected
? prev.filter((s) => s !== id)
: [...prev, id]
)
}}
onFilteredCountChange={setFilteredCount}
/>
</div>
<footer className={styles.footer}>
<Button
@@ -173,12 +210,17 @@ export default function FilterAndSortModal({
defaultMessage: "See results ({ count })",
},
{
count: resultCount,
count: filteredCount
? filteredCount
: unfilteredResultCount,
}
)}
</Button>
<Button
onClick={() => setFilters([])}
onClick={() => {
setSelectedFilters([])
setFilteredCount(unfilteredResultCount)
}}
intent="text"
size="medium"
theme="base"

View File

@@ -0,0 +1,42 @@
.container {
min-width: 272px;
}
.container > div {
display: flex;
flex-direction: column;
gap: var(--Space-x3);
}
.facilities {
padding-bottom: var(--Space-x3);
}
.facilities:first-of-type {
border-bottom: 1px solid var(--Base-Border-Subtle);
}
.facilities ul {
margin-top: var(--Space-x2);
}
.facilities:last-child {
padding-bottom: 0;
}
.filter {
display: grid;
grid-template-columns: repeat(2, minmax(min-content, max-content));
gap: var(--Space-x15);
margin-bottom: var(--Space-x1);
align-items: center;
}
.filter:first-child {
margin-top: var(--Space-x1);
}
.filter input[type="checkbox"] {
width: 1.25rem;
height: 1.25rem;
margin: 0;
}

View File

@@ -0,0 +1,131 @@
import { useEffect, useState } from "react"
import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography"
import Title from "@/components/TempDesignSystem/Text/Title"
import FilterCheckbox from "./FilterCheckbox"
import styles from "./filterContent.module.css"
import type {
CategorizedFilters,
HotelFilter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
interface FilterContentProps {
filters: CategorizedFilters
activeFilters: string[]
onChange: (id: string) => void
onFilteredCountChange?: (count: number) => void
className?: string
}
export default function FilterContent({
filters,
activeFilters,
onChange,
className,
onFilteredCountChange = () => undefined,
}: FilterContentProps) {
const intl = useIntl()
const [filteredHotelIds, setFilteredHotelIds] = useState<string[]>([])
useEffect(() => {
if (activeFilters.length) {
const allFilters = [
...filters.facilityFilters,
...filters.surroundingsFilters,
]
setFilteredHotelIds(
allFilters
.filter((f) => activeFilters.includes(f.id.toString()))
.map((f) => f.hotelIds)
.reduce((accumulatedHotelIds, currentHotelIds) =>
accumulatedHotelIds.filter((hotelId) =>
currentHotelIds.includes(hotelId)
)
)
)
} else {
setFilteredHotelIds([])
}
}, [filters, activeFilters, setFilteredHotelIds])
useEffect(() => {
onFilteredCountChange(filteredHotelIds.length)
}, [filteredHotelIds, onFilteredCountChange])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
function filterOutput(filters: HotelFilter[]) {
return filters.map((filter) => {
const isDisabled = filteredHotelIds.length
? !filter.hotelIds.some((hotelId) => filteredHotelIds.includes(hotelId))
: false
const combinedFiltersCount = filteredHotelIds.filter((id) =>
filter.hotelIds.includes(id)
).length
const filterCount = filter.hotelIds.length
return (
<li key={filter.id} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={onChange}
isSelected={activeFilters.some((f) => f === filter.id.toString())}
isDisabled={isDisabled}
/>
{!isDisabled && (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<span>{`(${combinedFiltersCount > 0 ? combinedFiltersCount : filterCount})`}</span>
)}
</li>
)
})
}
return (
<aside className={`${styles.container} ${className}`}>
<div>
<Title as="h4">
{intl.formatMessage({
defaultMessage: "Filter by",
})}
</Title>
<div className={styles.facilities}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "Hotel facilities",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<ul>{filterOutput(filters.facilityFilters)}</ul>
</Typography>
</div>
<div className={styles.facilities}>
<Typography variant="Title/Subtitle/md">
<p>
{intl.formatMessage({
defaultMessage: "Hotel surroundings",
})}
</p>
</Typography>
<Typography variant="Body/Paragraph/mdRegular">
<ul>{filterOutput(filters.surroundingsFilters)}</ul>
</Typography>
</div>
</div>
</aside>
)
}

View File

@@ -0,0 +1,85 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
import { trackEvent } from "@/utils/tracking/base"
import FilterContent from "../FilterContent"
import type {
HotelFilter,
HotelFiltersProps,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
const searchParams = useSearchParams()
const pathname = usePathname()
useInitializeFiltersFromUrl()
const { toggleFilter, activeFilters } = useHotelFilterStore((state) => ({
toggleFilter: state.toggleFilter,
activeFilters: state.activeFilters,
}))
const trackFiltersEvent = useCallback(() => {
const facilityMap = new Map(
filters.facilityFilters.map((f) => [f.id.toString(), f.name])
)
const surroundingsMap = new Map(
filters.surroundingsFilters.map((f) => [f.id.toString(), f.name])
)
const hotelFacilitiesFilter = activeFilters
.filter((id) => facilityMap.has(id))
.map((id) => facilityMap.get(id))
.join(",")
const hotelSurroundingsFilter = activeFilters
.filter((id) => surroundingsMap.has(id))
.map((id) => surroundingsMap.get(id))
.join(",")
trackEvent({
event: "filterUsed",
filter: {
filtersUsed: `Filters values - hotelfacilities:${hotelFacilitiesFilter}|hotelsurroundings:${hotelSurroundingsFilter}`,
},
})
}, [activeFilters, filters.facilityFilters, filters.surroundingsFilters])
// Update the URL when the filters changes
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams)
const values = activeFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
} else {
newSearchParams.set("filters", values)
}
if (values !== searchParams.get("filters")) {
if (values) {
trackFiltersEvent()
}
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
}
}, [activeFilters, pathname, searchParams, trackFiltersEvent])
return (
<FilterContent
className={className}
filters={filters}
activeFilters={activeFilters}
onChange={toggleFilter}
/>
)
}

View File

@@ -0,0 +1,2 @@
export { default as FilterAndSortModal } from "./FilterAndSortModal"
export { default as HotelFilter } from "./HotelFilter"

View File

@@ -1,153 +0,0 @@
"use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useIntl } from "react-intl"
import { useHotelFilterStore } from "@/stores/hotel-filters"
import Subtitle from "@/components/TempDesignSystem/Text/Subtitle"
import Title from "@/components/TempDesignSystem/Text/Title"
import useInitializeFiltersFromUrl from "@/hooks/useInitializeFiltersFromUrl"
import { trackEvent } from "@/utils/tracking/base"
import FilterCheckbox from "./FilterCheckbox"
import styles from "./hotelFilter.module.css"
import type {
HotelFilter,
HotelFiltersProps,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters"
export default function HotelFilter({ className, filters }: HotelFiltersProps) {
const intl = useIntl()
const searchParams = useSearchParams()
const pathname = usePathname()
const toggleFilter = useHotelFilterStore((state) => state.toggleFilter)
useInitializeFiltersFromUrl()
const activeFilters = useHotelFilterStore((state) => state.activeFilters)
const facilityMap = new Map(
filters.facilityFilters.map((f) => [f.id.toString(), f.name])
)
const surroundingsMap = new Map(
filters.surroundingsFilters.map((f) => [f.id.toString(), f.name])
)
function trackFiltersEvent() {
const hotelFacilitiesFilter = activeFilters
.filter((id) => facilityMap.has(id))
.map((id) => facilityMap.get(id))
.join(",")
const hotelSurroundingsFilter = activeFilters
.filter((id) => surroundingsMap.has(id))
.map((id) => surroundingsMap.get(id))
.join(",")
trackEvent({
event: "filterUsed",
filter: {
filtersUsed: `Filters values - hotelfacilities:${hotelFacilitiesFilter}|hotelsurroundings:${hotelSurroundingsFilter}`,
},
})
}
// Update the URL when the filters changes
useEffect(() => {
const newSearchParams = new URLSearchParams(searchParams)
const values = activeFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
} else {
newSearchParams.set("filters", values)
}
if (values !== searchParams.get("filters")) {
if (values) {
trackFiltersEvent()
}
window.history.replaceState(
null,
"",
`${pathname}?${newSearchParams.toString()}`
)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeFilters])
if (!filters.facilityFilters.length && !filters.surroundingsFilters.length) {
return null
}
let filteredHotelIds: string[] | undefined
if (activeFilters.length) {
const allFilters = [
...filters.facilityFilters,
...filters.surroundingsFilters,
]
filteredHotelIds = allFilters
.filter((f) => activeFilters.includes(f.id.toString()))
.map((f) => f.hotelIds)
.reduce((accumlatedHotelIds, currentHotelIds) =>
accumlatedHotelIds?.filter((hotelId) =>
currentHotelIds?.includes(hotelId)
)
)
}
function filterOutput(filters: HotelFilter[]) {
return filters.map((filter) => {
const isDisabled = filteredHotelIds?.length
? !filter.hotelIds?.some((hotelId) =>
filteredHotelIds.includes(hotelId)
)
: false
return (
<li key={`li-${filter.id}`} className={styles.filter}>
<FilterCheckbox
name={filter.name}
id={filter.id.toString()}
onChange={() => toggleFilter(filter.id.toString())}
isSelected={activeFilters.some((f) => f === filter.id.toString())}
isDisabled={isDisabled}
/>
{!isDisabled ? (
// eslint-disable-next-line formatjs/no-literal-string-in-jsx
<span>{`(${filteredHotelIds?.filter((id) => filter.hotelIds.includes(id))?.length ?? filter.hotelIds.length})`}</span>
) : null}
</li>
)
})
}
return (
<aside className={`${styles.container} ${className}`}>
<form>
<Title as="h4">
{intl.formatMessage({
defaultMessage: "Filter by",
})}
</Title>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({
defaultMessage: "Hotel facilities",
})}
</Subtitle>
<ul>{filterOutput(filters.facilityFilters)}</ul>
</div>
<div className={styles.facilities}>
<Subtitle>
{intl.formatMessage({
defaultMessage: "Hotel surroundings",
})}
</Subtitle>
<ul>{filterOutput(filters.surroundingsFilters)}</ul>
</div>
</form>
</aside>
)
}

View File

@@ -13,7 +13,7 @@ import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link"
import useLang from "@/hooks/useLang"
import FilterAndSortModal from "../FilterAndSortModal"
import FilterAndSortModal from "../Filters/FilterAndSortModal"
import styles from "./mobileMapButtonContainer.module.css"

View File

@@ -12,7 +12,10 @@ import styles from "./hotelListing.module.css"
import { HotelCardListingTypeEnum } from "@/types/components/hotelReservation/selectHotel/hotelCardListingProps"
import type { HotelListingProps } from "@/types/components/hotelReservation/selectHotel/map"
export default function HotelListing({ hotels }: HotelListingProps) {
export default function HotelListing({
hotels,
unfilteredHotelCount,
}: HotelListingProps) {
const { activeHotel } = useHotelsMapStore()
const isMobile = useMediaQuery("(max-width: 767px)")
@@ -25,6 +28,7 @@ export default function HotelListing({ hotels }: HotelListingProps) {
<HotelCardListing
hotelData={hotels}
type={HotelCardListingTypeEnum.MapListing}
unfilteredHotelCount={unfilteredHotelCount}
/>
</div>
)

View File

@@ -21,7 +21,7 @@ import { useScrollToTop } from "@/hooks/useScrollToTop"
import { debounce } from "@/utils/debounce"
import BookingCodeFilter from "../../BookingCodeFilter"
import FilterAndSortModal from "../../FilterAndSortModal"
import FilterAndSortModal from "../../Filters/FilterAndSortModal"
import HotelListing from "../HotelListing"
import { getVisibleHotels } from "./utils"
@@ -150,6 +150,8 @@ export default function SelectHotelContent({
const showBookingCodeFilter =
bookingCode && isBookingCodeRateAvailable && !isSpecialRate
const unfilteredHotelCount = hotelPins.length
return (
<div className={styles.container}>
<div className={styles.listingContainer} ref={listingContainerRef}>
@@ -184,7 +186,10 @@ export default function SelectHotelContent({
<RoomCardSkeleton />
</div>
) : (
<HotelListing hotels={visibleHotels} />
<HotelListing
hotels={visibleHotels}
unfilteredHotelCount={unfilteredHotelCount}
/>
)}
{showBackToTop && (
<BackToTopButton position="left" onClick={scrollToTop} />

View File

@@ -2,8 +2,8 @@ import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import HotelCardListing from "@/components/HotelReservation/HotelCardListing"
import BookingCodeFilter from "@/components/HotelReservation/SelectHotel/BookingCodeFilter"
import HotelFilter from "@/components/HotelReservation/SelectHotel/Filters/HotelFilter"
import HotelCount from "@/components/HotelReservation/SelectHotel/HotelCount"
import HotelFilter from "@/components/HotelReservation/SelectHotel/HotelFilter"
import HotelSorter from "@/components/HotelReservation/SelectHotel/HotelSorter"
import MobileMapButtonContainer from "@/components/HotelReservation/SelectHotel/MobileMapButtonContainer"
import NoAvailabilityAlert from "@/components/HotelReservation/SelectHotel/NoAvailabilityAlert"

View File

@@ -5,7 +5,8 @@ interface HotelFilterState {
toggleFilter: (filterId: string) => void
setFilters: (filters: string[]) => void
resultCount: number
setResultCount: (count: number) => void
unfilteredResultCount: number
setResultCount: (count: number, unfilteredCount?: number) => void
}
export const useHotelFilterStore = create<HotelFilterState>((set) => ({
@@ -22,5 +23,7 @@ export const useHotelFilterStore = create<HotelFilterState>((set) => ({
return { activeFilters: newFilters }
}),
resultCount: 0,
setResultCount: (count) => set({ resultCount: count }),
unfilteredResultCount: 0,
setResultCount: (count, unfilteredCount) =>
set({ resultCount: count, unfilteredResultCount: unfilteredCount ?? 0 }),
}))

View File

@@ -14,6 +14,7 @@ export type HotelData = {
export type HotelCardListingProps = {
hotelData: HotelResponse[]
unfilteredHotelCount?: number
type?: HotelCardListingTypeEnum
}

View File

@@ -10,6 +10,7 @@ import type { SelectHotelBooking } from "./selectHotel"
export interface HotelListingProps {
hotels: HotelResponse[]
unfilteredHotelCount: number
}
export interface SelectHotelMapProps {