Merged in fix/book-674-select-hotel-infinite-loop (pull request #3351)

fix(BOOK-674): Refactor how we handle hotel filters

* Refactor hotel filters store to URL state

* Rename hotel filter store


Approved-by: Joakim Jäderberg
This commit is contained in:
Anton Gunnarsson
2025-12-15 13:58:00 +00:00
parent 494bf2ba78
commit 713ca6562e
10 changed files with 120 additions and 139 deletions

View File

@@ -24,9 +24,10 @@ import {
BookingCodeFilterEnum, BookingCodeFilterEnum,
useBookingCodeFilterStore, useBookingCodeFilterStore,
} from "../../stores/bookingCode-filter" } from "../../stores/bookingCode-filter"
import { useHotelFilterStore } from "../../stores/hotel-filters" import { useHotelResultCountStore } from "../../stores/hotel-result-count"
import { useHotelsMapStore } from "../../stores/hotels-map" import { useHotelsMapStore } from "../../stores/hotels-map"
import { HotelDetailsSidePeek } from "../HotelDetailsSidePeek" import { HotelDetailsSidePeek } from "../HotelDetailsSidePeek"
import { useHotelFilters } from "../SelectHotel/Filters/useHotelFilters"
import { DEFAULT_SORT } from "../SelectHotel/HotelSorter" import { DEFAULT_SORT } from "../SelectHotel/HotelSorter"
import { getSortedHotels } from "./utils" import { getSortedHotels } from "./utils"
@@ -57,8 +58,10 @@ export default function HotelCardListing({
const intl = useIntl() const intl = useIntl()
const isUserLoggedIn = useIsLoggedIn() const isUserLoggedIn = useIsLoggedIn()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const activeFilters = useHotelFilterStore((state) => state.activeFilters) const [activeFilters] = useHotelFilters(null)
const setResultCount = useHotelFilterStore((state) => state.setResultCount) const setResultCount = useHotelResultCountStore(
(state) => state.setResultCount
)
const { activeHotel, activate, disengage, engage } = useHotelsMapStore() const { activeHotel, activate, disengage, engage } = useHotelsMapStore()
const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 }) const { showBackToTop, scrollToTop } = useScrollToTop({ threshold: 490 })
const activeCardRef = useRef<HTMLDivElement | null>(null) const activeCardRef = useRef<HTMLDivElement | null>(null)
@@ -150,7 +153,7 @@ export default function HotelCardListing({
if (type === HotelCardListingTypeEnum.PageListing) { if (type === HotelCardListingTypeEnum.PageListing) {
setResultCount(hotels.length, unfilteredHotelCount) setResultCount(hotels.length, unfilteredHotelCount)
} }
}, [hotels, setResultCount, type, unfilteredHotelCount]) }, [hotels.length, setResultCount, type, unfilteredHotelCount])
function isHotelActiveInMapView(hotelName: string): boolean { function isHotelActiveInMapView(hotelName: string): boolean {
return ( return (

View File

@@ -20,11 +20,11 @@ import { IconButton } from "@scandic-hotels/design-system/IconButton"
import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon" import { MaterialIcon } from "@scandic-hotels/design-system/Icons/MaterialIcon"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import useInitializeFiltersFromUrl from "../../../../hooks/useInitializeFiltersFromUrl"
import { SortOrder } from "../../../../misc/sortOrder" import { SortOrder } from "../../../../misc/sortOrder"
import { useHotelFilterStore } from "../../../../stores/hotel-filters" import { useHotelResultCountStore } from "../../../../stores/hotel-result-count"
import { DEFAULT_SORT } from "../../HotelSorter" import { DEFAULT_SORT } from "../../HotelSorter"
import FilterContent from "../FilterContent" import FilterContent from "../FilterContent"
import { useHotelFilters } from "../useHotelFilters"
import styles from "./filterAndSortModal.module.css" import styles from "./filterAndSortModal.module.css"
@@ -46,17 +46,16 @@ export default function FilterAndSortModal({
}: FilterAndSortModalProps) { }: FilterAndSortModalProps) {
const intl = useIntl() const intl = useIntl()
useInitializeFiltersFromUrl()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const pathname = usePathname() const pathname = usePathname()
const { resultCount, setFilters, activeFilters, unfilteredResultCount } = const { resultCount, unfilteredResultCount } = useHotelResultCountStore(
useHotelFilterStore((state) => ({ (state) => ({
resultCount: state.resultCount, resultCount: state.resultCount,
setFilters: state.setFilters,
activeFilters: state.activeFilters,
unfilteredResultCount: state.unfilteredResultCount, unfilteredResultCount: state.unfilteredResultCount,
})) })
)
const [activeFilters, { setFilters }] = useHotelFilters(filters)
const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT) const [sort, setSort] = useState(searchParams.get("sort") ?? DEFAULT_SORT)
@@ -116,14 +115,6 @@ export default function FilterAndSortModal({
} }
const newSearchParams = new URLSearchParams(searchParams) const newSearchParams = new URLSearchParams(searchParams)
const values = selectedFilters.join(",")
if (values === "") {
newSearchParams.delete("filters")
} else {
newSearchParams.set("filters", values)
}
newSearchParams.set("sort", sort) newSearchParams.set("sort", sort)
window.history.replaceState( window.history.replaceState(

View File

@@ -1,13 +1,7 @@
"use client" "use client"
import { usePathname, useSearchParams } from "next/navigation"
import { useCallback, useEffect } from "react"
import { trackEvent } from "@scandic-hotels/tracking/base"
import useInitializeFiltersFromUrl from "../../../../hooks/useInitializeFiltersFromUrl"
import { useHotelFilterStore } from "../../../../stores/hotel-filters"
import FilterContent from "../FilterContent" import FilterContent from "../FilterContent"
import { useHotelFilters } from "../useHotelFilters"
import type { CategorizedHotelFilters } from "../../../../types" import type { CategorizedHotelFilters } from "../../../../types"
@@ -17,63 +11,7 @@ type HotelFiltersProps = {
} }
export default function HotelFilter({ className, filters }: HotelFiltersProps) { export default function HotelFilter({ className, filters }: HotelFiltersProps) {
const searchParams = useSearchParams() const [activeFilters, { toggleFilter }] = useHotelFilters(filters)
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 ( return (
<FilterContent <FilterContent

View File

@@ -0,0 +1,76 @@
import { parseAsArrayOf, parseAsString, useQueryState } from "nuqs"
import { logger } from "@scandic-hotels/common/logger"
import { trackEvent } from "@scandic-hotels/tracking/base"
import type { CategorizedHotelFilters } from "../../../types"
export function useHotelFilters(filters: CategorizedHotelFilters | null) {
const [filterIds, setFilterIds] = useQueryState(
"filters",
parseAsArrayOf(parseAsString).withOptions({
history: "replace",
})
)
const update = (filterIds: string[]) => {
if (!filters) {
// This handles a usage behavior where filterIds are read but never set, in which case we don't pass filters
// to this hook. While filters are only used for tracking purposes, this prevents us from incorrectly
// updating filterIds without providing the filters and hence getting the correct tracking data.
logger.warn(
"Updating filters are not supported when filters parameter is null in useHotelFilters hook"
)
return
}
setFilterIds(filterIds.length > 0 ? filterIds : null)
trackFiltersEvent({
activeFilterIds: filterIds,
filters,
})
}
const toggleFilter = (filterId: string) => {
const newFiltersIds = filterIds?.includes(filterId)
? filterIds.filter((id) => id !== filterId)
: [...(filterIds || []), filterId]
update(newFiltersIds)
}
const activeFilters = filterIds || []
return [activeFilters, { toggleFilter, setFilters: update }] as const
}
const trackFiltersEvent = ({
activeFilterIds,
filters,
}: {
activeFilterIds: string[]
filters: CategorizedHotelFilters
}) => {
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 = activeFilterIds
.filter((id) => facilityMap.has(id))
.map((id) => facilityMap.get(id))
.join(",")
const hotelSurroundingsFilter = activeFilterIds
.filter((id) => surroundingsMap.has(id))
.map((id) => surroundingsMap.get(id))
.join(",")
trackEvent({
event: "filterUsed",
filter: {
filtersUsed: `Filters values - hotelfacilities:${hotelFacilitiesFilter}|hotelsurroundings:${hotelSurroundingsFilter}`,
},
})
}

View File

@@ -4,11 +4,11 @@ import { useIntl } from "react-intl"
import { Typography } from "@scandic-hotels/design-system/Typography" import { Typography } from "@scandic-hotels/design-system/Typography"
import { useHotelFilterStore } from "../../../stores/hotel-filters" import { useHotelResultCountStore } from "../../../stores/hotel-result-count"
export default function HotelCount() { export default function HotelCount() {
const intl = useIntl() const intl = useIntl()
const resultCount = useHotelFilterStore((state) => state.resultCount) const resultCount = useHotelResultCountStore((state) => state.resultCount)
return ( return (
<Typography variant="Title/Subtitle/md"> <Typography variant="Title/Subtitle/md">

View File

@@ -7,7 +7,8 @@ import { alternativeHotels } from "@scandic-hotels/common/constants/routes/hotel
import { Alert } from "@scandic-hotels/design-system/Alert" import { Alert } from "@scandic-hotels/design-system/Alert"
import useLang from "../../hooks/useLang" import useLang from "../../hooks/useLang"
import { useHotelFilterStore } from "../../stores/hotel-filters" import { useHotelResultCountStore } from "../../stores/hotel-result-count"
import { useHotelFilters } from "./Filters/useHotelFilters"
import type { Hotel } from "@scandic-hotels/trpc/types/hotel" import type { Hotel } from "@scandic-hotels/trpc/types/hotel"
@@ -31,10 +32,10 @@ export default function NoAvailabilityAlert({
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
const { resultCount, activeFilters } = useHotelFilterStore((state) => ({ const { resultCount } = useHotelResultCountStore((state) => ({
resultCount: state.resultCount, resultCount: state.resultCount,
activeFilters: state.activeFilters,
})) }))
const [activeFilters] = useHotelFilters(null)
if (activeFilters.length > 0 && resultCount === 0) { if (activeFilters.length > 0 && resultCount === 0) {
return ( return (

View File

@@ -27,12 +27,13 @@ import {
BookingCodeFilterEnum, BookingCodeFilterEnum,
useBookingCodeFilterStore, useBookingCodeFilterStore,
} from "../../../../stores/bookingCode-filter" } from "../../../../stores/bookingCode-filter"
import { useHotelFilterStore } from "../../../../stores/hotel-filters" import { useHotelResultCountStore } from "../../../../stores/hotel-result-count"
import { useHotelsMapStore } from "../../../../stores/hotels-map" import { useHotelsMapStore } from "../../../../stores/hotels-map"
import BookingCodeFilter from "../../../BookingCodeFilter" import BookingCodeFilter from "../../../BookingCodeFilter"
import { getHotelPins } from "../../../HotelCardDialogListing/utils" import { getHotelPins } from "../../../HotelCardDialogListing/utils"
import { RoomCardSkeleton } from "../../../RoomCardSkeleton/RoomCardSkeleton" import { RoomCardSkeleton } from "../../../RoomCardSkeleton/RoomCardSkeleton"
import FilterAndSortModal from "../../Filters/FilterAndSortModal" import FilterAndSortModal from "../../Filters/FilterAndSortModal"
import { useHotelFilters } from "../../Filters/useHotelFilters"
import { type HotelResponse } from "../../helpers" import { type HotelResponse } from "../../helpers"
import HotelListing from "../HotelListing" import HotelListing from "../HotelListing"
import { getVisibleHotels } from "./utils" import { getVisibleHotels } from "./utils"
@@ -75,8 +76,10 @@ export function SelectHotelMapContent({
const [showSkeleton, setShowSkeleton] = useState<boolean>(true) const [showSkeleton, setShowSkeleton] = useState<boolean>(true)
const listingContainerRef = useRef<HTMLDivElement | null>(null) const listingContainerRef = useRef<HTMLDivElement | null>(null)
const activeFilters = useHotelFilterStore((state) => state.activeFilters) const [activeFilters] = useHotelFilters(null)
const setResultCount = useHotelFilterStore((state) => state.setResultCount) const setResultCount = useHotelResultCountStore(
(state) => state.setResultCount
)
const pointsCurrency = useGetPointsCurrency() const pointsCurrency = useGetPointsCurrency()
const hotelMapStore = useHotelsMapStore() const hotelMapStore = useHotelsMapStore()

View File

@@ -1,18 +0,0 @@
import { useSearchParams } from "next/navigation"
import { useEffect } from "react"
import { useHotelFilterStore } from "../stores/hotel-filters"
export default function useInitializeFiltersFromUrl() {
const searchParams = useSearchParams()
const setFilters = useHotelFilterStore((state) => state.setFilters)
useEffect(() => {
const filtersFromUrl = searchParams.get("filters")
if (filtersFromUrl) {
setFilters(filtersFromUrl.split(","))
} else {
setFilters([])
}
}, [searchParams, setFilters])
}

View File

@@ -1,29 +0,0 @@
import { create } from "zustand"
interface HotelFilterState {
activeFilters: string[]
toggleFilter: (filterId: string) => void
setFilters: (filters: string[]) => void
resultCount: number
unfilteredResultCount: number
setResultCount: (count: number, unfilteredCount: number) => void
}
export const useHotelFilterStore = create<HotelFilterState>((set) => ({
activeFilters: [],
setFilters: (filters) => set({ activeFilters: filters }),
toggleFilter: (filterId: string) =>
set((state) => {
const isActive = state.activeFilters.includes(filterId)
const newFilters = isActive
? state.activeFilters.filter((id) => id !== filterId)
: [...state.activeFilters, filterId]
return { activeFilters: newFilters }
}),
resultCount: 0,
unfilteredResultCount: 0,
setResultCount: (count, unfilteredCount) =>
set({ resultCount: count, unfilteredResultCount: unfilteredCount }),
}))

View File

@@ -0,0 +1,16 @@
import { create } from "zustand"
interface HotelResultCountState {
resultCount: number
unfilteredResultCount: number
setResultCount: (count: number, unfilteredCount: number) => void
}
export const useHotelResultCountStore = create<HotelResultCountState>(
(set) => ({
resultCount: 0,
unfilteredResultCount: 0,
setResultCount: (count, unfilteredCount) =>
set({ resultCount: count, unfilteredResultCount: unfilteredCount }),
})
)