feat(BOOK-54): Adjusted filter functionality to save filters as query parameters instead of paths

* feat(BOOK-54): Destination filters now matching on id instead of slug in preparation for filters from Contentstack

Approved-by: Chuma Mcphoy (We Ahead)
This commit is contained in:
Erik Tiekstra
2025-09-18 13:03:01 +00:00
parent 32a817fa72
commit 948c86479a
18 changed files with 136 additions and 102 deletions

View File

@@ -10,16 +10,13 @@ import type { PageArgs } from "@/types/params"
export { generateMetadata } from "@/utils/metadata/generateMetadata"
export default async function DestinationCityPagePage(
props: PageArgs<object, { view?: "map"; filterFromUrl?: string }>
props: PageArgs<object, { view?: "map" }>
) {
const searchParams = await props.searchParams
return (
<div className={styles.page}>
<Suspense fallback={<DestinationCityPageSkeleton />}>
<DestinationCityPage
isMapView={searchParams.view === "map"}
filterFromUrl={searchParams.filterFromUrl}
/>
<DestinationCityPage isMapView={searchParams.view === "map"} />
</Suspense>
</div>
)

View File

@@ -5,20 +5,16 @@ import DestinationCountryPageSkeleton from "@/components/ContentType/Destination
import styles from "./page.module.css"
import type { PageArgs } from "@/types/params"
export { generateMetadata } from "@/utils/metadata/generateMetadata"
export default async function DestinationCountryPagePage(
props: PageArgs<object, { view?: "map"; filterFromUrl?: string }>
) {
const searchParams = await props.searchParams
export default async function DestinationCountryPagePage() {
// props: PageArgs<{}, { view?: "map"; }>
// const searchParams = await props.searchParams
return (
<div className={styles.page}>
<Suspense fallback={<DestinationCountryPageSkeleton />}>
<DestinationCountryPage
// isMapView={searchParams.view === "map"} // Disabled until further notice
filterFromUrl={searchParams.filterFromUrl}
isMapView={false}
/>
</Suspense>

View File

@@ -44,16 +44,24 @@ export default function HotelList() {
return
}
function handleBoundsChanged() {
debouncedUpdateVisibleHotels()
}
coreLib.event.addListener(map, "bounds_changed", handleBoundsChanged)
coreLib.event.addListener(
map,
"bounds_changed",
debouncedUpdateVisibleHotels
)
return () => {
coreLib.event.clearListeners(map, "bounds_changed")
}
}, [map, coreLib, debouncedUpdateVisibleHotels])
useEffect(() => {
if (!map) {
return
}
setVisibleHotels(getVisibleHotels(activeHotels, map))
}, [map, activeHotels])
if (isLoading) {
return <HotelListSkeleton />
}

View File

@@ -29,11 +29,11 @@ export default function CityMap({
defaultLocation,
}: CityMapProps) {
const intl = useIntl()
const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
const { activeHotels, allFilters, filterFromUrl } = useDestinationDataStore(
(state) => ({
activeHotels: state.activeHotels,
allFilters: state.allFilters,
activeFilters: state.activeFilters,
filterFromUrl: state.filterFromUrl,
})
)
@@ -47,7 +47,7 @@ export default function CityMap({
>
<Typography variant="Title/sm">
<h1 className={styles.title}>
{getHeadingText(intl, city.name, allFilters, activeFilters[0])}
{getHeadingText(intl, city.name, allFilters, filterFromUrl)}
</h1>
</Typography>
<HotelList />

View File

@@ -28,7 +28,6 @@ import DestinationPageSidePeek from "../Sidepeek"
import StaticMap from "../StaticMap"
import TopImages from "../TopImages"
import DestinationTracking from "../Tracking"
import { getHeadingText } from "../utils"
import CityMap from "./CityMap"
import DestinationCityPageSkeleton from "./DestinationCityPageSkeleton"
@@ -36,12 +35,10 @@ import styles from "./destinationCityPage.module.css"
interface DestinationCityPageProps {
isMapView: boolean
filterFromUrl?: string
}
export default async function DestinationCityPage({
isMapView,
filterFromUrl,
}: DestinationCityPageProps) {
const intl = await getIntl()
const lang = await getLang()
@@ -94,7 +91,6 @@ export default async function DestinationCityPage({
<DestinationDataProvider
allHotels={allHotels}
allFilters={allFilters}
filterFromUrl={filterFromUrl}
sortItems={sortItems}
pathname={pathname}
>
@@ -120,16 +116,7 @@ export default async function DestinationCityPage({
{blocks && <Blocks blocks={blocks} />}
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper
hasActiveFilter={!!filterFromUrl}
preamble={preamble}
heading={getHeadingText(
intl,
city.name,
allFilters,
filterFromUrl
)}
>
<SidebarContentWrapper preamble={preamble} location={city.name}>
<ExperienceList experiences={experiences} />
{has_sidepeek && sidepeek_content ? (
<DestinationPageSidePeek

View File

@@ -28,11 +28,11 @@ export default function CountryMap({
defaultLocation,
}: CountryMapProps) {
const intl = useIntl()
const { activeHotels, allFilters, activeFilters } = useDestinationDataStore(
const { activeHotels, allFilters, filterFromUrl } = useDestinationDataStore(
(state) => ({
activeHotels: state.activeHotels,
allFilters: state.allFilters,
activeFilters: state.activeFilters,
filterFromUrl: state.filterFromUrl,
})
)
@@ -46,7 +46,7 @@ export default function CountryMap({
>
<Typography variant="Title/sm">
<h1 className={styles.title}>
{getHeadingText(intl, country, allFilters, activeFilters[0])}
{getHeadingText(intl, country, allFilters, filterFromUrl)}
</h1>
</Typography>
<CityList />

View File

@@ -28,7 +28,6 @@ import SidebarContentWrapper from "../SidebarContentWrapper"
import DestinationPageSidePeek from "../Sidepeek"
import TopImages from "../TopImages"
import DestinationTracking from "../Tracking"
import { getHeadingText } from "../utils"
import CountryMap from "./CountryMap"
import DestinationCountryPageSkeleton from "./DestinationCountryPageSkeleton"
@@ -36,12 +35,10 @@ import styles from "./destinationCountryPage.module.css"
interface DestinationCountryPageProps {
isMapView: boolean
filterFromUrl?: string
}
export default async function DestinationCountryPage({
isMapView,
filterFromUrl,
}: DestinationCountryPageProps) {
const intl = await getIntl()
const lang = await getLang()
@@ -93,7 +90,6 @@ export default async function DestinationCountryPage({
allHotels={allHotels}
allCities={allCities}
allFilters={allFilters}
filterFromUrl={filterFromUrl}
sortItems={sortItems}
pathname={pathname}
>
@@ -123,14 +119,8 @@ export default async function DestinationCountryPage({
</main>
<aside className={styles.sidebar}>
<SidebarContentWrapper
hasActiveFilter={!!filterFromUrl}
preamble={preamble}
heading={getHeadingText(
intl,
translatedCountry,
allFilters,
filterFromUrl
)}
location={translatedCountry}
>
<ExperienceList experiences={experiences} />
{has_sidepeek && sidepeek_content ? (

View File

@@ -1,37 +1,47 @@
"use client"
import { useRef } from "react"
import { useIntl } from "react-intl"
import useStickyPosition from "@scandic-hotels/common/hooks/useStickyPosition"
import { StickyElementNameEnum } from "@scandic-hotels/common/stores/sticky-position"
import { Typography } from "@scandic-hotels/design-system/Typography"
import { useDestinationDataStore } from "@/stores/destination-data"
import { getHeadingText } from "@/components/ContentType/DestinationPage/utils"
import styles from "./sidebarContentWrapper.module.css"
interface SidebarContentWrapperProps extends React.PropsWithChildren {
preamble: string
hasActiveFilter: boolean
heading: string
location: string
}
export default function SidebarContentWrapper({
preamble,
hasActiveFilter,
heading,
location,
children,
}: SidebarContentWrapperProps) {
const intl = useIntl()
const sidebarRef = useRef<HTMLDivElement>(null)
const { allFilters, filterFromUrl } = useDestinationDataStore((state) => ({
allFilters: state.allFilters,
filterFromUrl: state.filterFromUrl,
}))
useStickyPosition({
ref: sidebarRef,
name: StickyElementNameEnum.DESTINATION_SIDEBAR,
})
const heading = getHeadingText(intl, location, allFilters, filterFromUrl)
return (
<div ref={sidebarRef} className={styles.sidebarContent}>
<Typography variant="Title/md">
<h1 className={styles.heading}>{heading}</h1>
</Typography>
{!hasActiveFilter ? (
{!filterFromUrl ? (
<Typography variant="Body/Paragraph/mdRegular">
<p className={styles.text}>{preamble}</p>
</Typography>

View File

@@ -1,18 +1,21 @@
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
import type {
CategorizedHotelFilters,
HotelFilter,
} from "@scandic-hotels/trpc/types/hotel"
import type { IntlShape } from "react-intl"
export function getHeadingText(
intl: IntlShape,
location: string,
allFilters: CategorizedHotelFilters,
filter?: string
filterFromUrl: HotelFilter | null
) {
if (filter) {
if (filterFromUrl) {
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
(f) => f.id === filterFromUrl.id
)
const surroudingsFilter = allFilters.surroundingsFilters.find(
(f) => f.slug === filter
(f) => f.id === filterFromUrl.id
)
if (facilityFilter) {

View File

@@ -53,8 +53,10 @@ export default function Filter({ filters }: FilterProps) {
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
onChange={() => togglePendingFilter(filter)}
isSelected={
!!pendingFilters.find((pf) => pf.id === filter.id)
}
/>
</li>
))}
@@ -75,8 +77,10 @@ export default function Filter({ filters }: FilterProps) {
<Checkbox
name={filter.name}
value={filter.slug}
onChange={() => togglePendingFilter(filter.slug)}
isSelected={!!pendingFilters.find((f) => f === filter.slug)}
onChange={() => togglePendingFilter(filter)}
isSelected={
!!pendingFilters.find((pf) => pf.id === filter.id)
}
/>
</li>
))}

View File

@@ -43,6 +43,7 @@ export default function DestinationFilterAndSort({
basePath,
pendingCount,
activeFilters,
filterFromUrl,
clearPendingFilters,
resetPendingValues,
setIsLoading,
@@ -56,6 +57,7 @@ export default function DestinationFilterAndSort({
pendingCount:
listType === "city" ? state.pendingCityCount : state.pendingHotelCount,
activeFilters: state.activeFilters,
filterFromUrl: state.filterFromUrl,
clearPendingFilters: state.actions.clearPendingFilters,
resetPendingValues: state.actions.resetPendingValues,
setIsLoading: state.actions.setIsLoading,
@@ -84,7 +86,7 @@ export default function DestinationFilterAndSort({
function submitAndClose(close: () => void) {
setIsLoading(true)
const sort = pendingSort
const filters = pendingFilters
const filters = [...pendingFilters]
const parsedUrl = new URL(window.location.href)
const searchParams = parsedUrl.searchParams
if (sort === defaultSort && searchParams.has("sort")) {
@@ -92,14 +94,26 @@ export default function DestinationFilterAndSort({
} else if (sort !== defaultSort) {
searchParams.set("sort", sort)
}
const [firstFilter, ...remainingFilters] = filters
parsedUrl.pathname = basePath
if (firstFilter) {
parsedUrl.pathname += `/${firstFilter}`
// We need to check if one of the filters in the URL is also in the pending filters.
// This is only the case when the user has navigated directly to a filter URL
// e.g. /destination/copenhagen/spa and then opens the filter modal and changes the sort or adds/removes other filters.
// In this case we will keep the filter in the URL and not add it to the "filter" search param.
if (filterFromUrl) {
const foundFilterIndex = filters.findIndex(
(f) => f.id === filterFromUrl.id
)
if (foundFilterIndex !== -1) {
parsedUrl.pathname += `/${filterFromUrl.slug}`
filters.splice(foundFilterIndex, 1)
}
}
if (remainingFilters.length > 0) {
searchParams.set("filter", remainingFilters.join("&"))
if (filters.length > 0) {
const filterSlugs = filters.map((f) => f.slug)
searchParams.set("filter", filterSlugs.join("&"))
} else {
searchParams.delete("filter")
}

View File

@@ -24,10 +24,14 @@ export default function DestinationDataProviderContent({
const filterParam = searchParams.get("filter")
const filters = []
const pathParts = currentPathname.split("/")
const lastPathPart = pathParts[pathParts.length - 1]
const filterFromUrl = pathParts[pathParts.length - 1]
// Even though the user filter is stored in the query param,
// we also support having it as the last part of the pathname
// e.g. /destination/copenhagen/spa
// In this case we need to add it to the filters array
if (basePath !== currentPathname) {
filters.push(lastPathPart)
filters.push(filterFromUrl)
}
if (filterParam) {
filters.push(...filterParam.split("&"))

View File

@@ -15,7 +15,6 @@ export default function DestinationDataProvider({
allCities = [],
allHotels,
allFilters,
filterFromUrl,
sortItems,
pathname,
children,
@@ -28,7 +27,6 @@ export default function DestinationDataProvider({
allCities,
allHotels,
allFilters,
filterFromUrl,
pathname,
sortItems,
searchParams,

View File

@@ -1,4 +1,5 @@
import {
type HotelFilter,
type HotelListingHotelData,
type HotelSortItem,
HotelSortOption,
@@ -25,12 +26,12 @@ const HOTEL_SORTING_STRATEGIES: Partial<
export function getFilteredHotels(
hotels: HotelListingHotelData[],
filters: string[]
filters: HotelFilter[]
) {
if (filters.length) {
return hotels.filter(({ hotel }) =>
filters.every((filter) =>
hotel.detailedFacilities.some((facility) => facility.slug === filter)
hotel.detailedFacilities.some((facility) => facility.id === filter.id)
)
)
}

View File

@@ -29,26 +29,30 @@ export function createDestinationDataStore({
allCities,
allHotels,
allFilters,
filterFromUrl,
pathname,
sortItems,
searchParams,
}: InitialState) {
const defaultSort =
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
const allFilterSlugs = Object.values(allFilters).flatMap(
(filter: HotelFilter[]) => filter.map((f) => f.slug)
)
const flattenedFilters = Object.values(allFilters).flat<HotelFilter[]>()
const allFilterSlugs = flattenedFilters.map((filter) => filter.slug)
const activeFilters: HotelFilter[] = []
let filterFromUrl: HotelFilter | null = null
const activeFilters: string[] = filterFromUrl ? [filterFromUrl] : []
const basePathnameWithoutFilters = getBasePathNameWithoutFilters(
pathname,
allFilterSlugs
)
if (basePathnameWithoutFilters !== pathname) {
const pathParts = pathname.split("/")
const lastPathPart = pathParts[pathParts.length - 1]
activeFilters.push(lastPathPart)
filterFromUrl =
flattenedFilters.find(
(filter) => filter.slug === pathParts[pathParts.length - 1]
) ?? null
if (filterFromUrl) {
activeFilters.push(filterFromUrl)
}
}
let activeSort = defaultSort
@@ -65,13 +69,16 @@ export function createDestinationDataStore({
return create<DestinationDataState>((set) => ({
actions: {
updateActiveFiltersAndSort(filters, sort) {
updateActiveFiltersAndSort(filterSlugs, sort) {
return set(
produce((state: DestinationDataState) => {
const newSort =
sort && isValidSortOption(sort, state.sortItems)
? sort
: state.defaultSort
const filters = flattenedFilters.filter((filter) =>
filterSlugs.includes(filter.slug)
)
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, newSort)
const filteredCities = state.allHotels.length
@@ -86,16 +93,22 @@ export function createDestinationDataStore({
if (
JSON.stringify(filters) !== JSON.stringify(state.activeFilters)
) {
const facilityFiltersUsed = filters.filter((f) =>
state.allFilters.facilityFilters
.map((ff) => ff.slug)
.includes(f)
)
const surroundingsFiltersUsed = filters.filter((f) =>
state.allFilters.surroundingsFilters
.map((sf) => sf.slug)
.includes(f)
)
const facilityFiltersUsed = filters
.filter(
(f) =>
!!state.allFilters.facilityFilters.find(
(ff) => ff.id === f.id
)
)
.map((f) => f.slug)
const surroundingsFiltersUsed = filters
.filter(
(f) =>
!!state.allFilters.surroundingsFilters.find(
(sf) => sf.id === f.id
)
)
.map((f) => f.slug)
trackFilterChangeEvent(
facilityFiltersUsed,
@@ -133,9 +146,12 @@ export function createDestinationDataStore({
togglePendingFilter(filter) {
return set(
produce((state: DestinationDataState) => {
const isActive = state.pendingFilters.includes(filter)
const filterId = filter.id
const isActive = !!state.pendingFilters.find(
(pf) => pf.id === filterId
)
const filters = isActive
? state.pendingFilters.filter((f) => f !== filter)
? state.pendingFilters.filter((f) => f.id !== filterId)
: [...state.pendingFilters, filter]
const pendingHotels = getFilteredHotels(state.allHotels, filters)
const pendingCities = state.allHotels.length
@@ -180,7 +196,7 @@ export function createDestinationDataStore({
activeFilters,
pendingFilters: activeFilters,
allFilters,
allFilterSlugs,
filterFromUrl,
basePathnameWithoutFilters,
sortItems,
isLoading: false,

View File

@@ -1,6 +1,7 @@
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type {
CategorizedHotelFilters,
HotelFilter,
HotelListingHotelData,
HotelSortItem,
HotelSortOption,
@@ -8,9 +9,12 @@ import type {
import type { ReadonlyURLSearchParams } from "next/navigation"
interface Actions {
updateActiveFiltersAndSort: (filters: string[], sort: string | null) => void
updateActiveFiltersAndSort: (
filterSlugs: string[],
sort: string | null
) => void
setPendingSort: (sort: HotelSortOption) => void
togglePendingFilter: (filter: string) => void
togglePendingFilter: (filter: HotelFilter) => void
clearPendingFilters: () => void
resetPendingValues: () => void
setIsLoading: (isLoading: boolean) => void
@@ -31,12 +35,12 @@ export interface DestinationDataState {
pendingSort: HotelSortOption
activeSort: HotelSortOption
defaultSort: HotelSortOption
pendingFilters: string[]
activeFilters: string[]
pendingFilters: HotelFilter[]
activeFilters: HotelFilter[]
filterFromUrl: HotelFilter | null
pendingHotelCount: number
pendingCityCount: number
allFilters: CategorizedHotelFilters
allFilterSlugs: string[]
basePathnameWithoutFilters: string
sortItems: HotelSortItem[]
isLoading: boolean
@@ -48,6 +52,5 @@ export interface InitialState
"allHotels" | "allCities" | "sortItems" | "allFilters"
> {
pathname: string
filterFromUrl?: string
searchParams: ReadonlyURLSearchParams
}

View File

@@ -1,3 +1,4 @@
import type { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
import type { z } from "zod"
import type {
@@ -87,6 +88,7 @@ export type HotelInput = z.input<typeof hotelInputSchema>
export type RoomType = Pick<Room, "roomTypes" | "name">
export interface HotelFilter {
id: FacilityEnum
name: string
slug: string
filterType: string

View File

@@ -73,6 +73,7 @@ export function getFiltersFromHotels(
)
return filter
? {
id: filter.id,
name: filter.name,
slug: filter.slug,
filterType: filter.filter,