feat(BOOK-463): Fetching hotel filters from CMS and using these inside the destination pages and select hotel page

* feat(BOOK-463): Fetching hotel filters from CMS and using these inside the destination pages

* fix(BOOK-698): fetch hotel filters from CMS on select hotel page

Approved-by: Bianca Widstam
This commit is contained in:
Erik Tiekstra
2026-01-12 12:02:25 +00:00
parent b2ca2c2612
commit 0c6a4cf186
40 changed files with 732 additions and 399 deletions

View File

@@ -4,12 +4,13 @@ import {
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { getHotelsByCSFilter } from "@/lib/trpc/memoizedRequests"
import {
getHotelFilters,
getHotelsByCSFilter,
} from "@/lib/trpc/memoizedRequests"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import HotelListingDataProvider from "@/providers/HotelListingDataProvider"
import CampaignHotelListingSkeleton from "./CampaignHotelListingSkeleton"
@@ -35,14 +36,13 @@ export default async function CampaignHotelListing({
isMainBlock = false,
}: CampaignHotelListingProps) {
const intl = await getIntl()
const lang = await getLang()
const hotels = await getHotelsByCSFilter({ hotelsToInclude: hotelIds })
if (!hotels.length) {
return null
}
const allFilters = getFiltersFromHotels(hotels, lang)
const allFilters = await getHotelFilters()
const sortItems: HotelSortItem[] = [
{
label: intl.formatMessage({

View File

@@ -5,11 +5,11 @@ import {
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { env } from "@/env/server"
import {
getDestinationCityPage,
getHotelFilters,
getHotelsByCityIdentifier,
} from "@/lib/trpc/memoizedRequests"
@@ -17,7 +17,6 @@ import Breadcrumbs from "@/components/Breadcrumbs"
import { SeoFilters } from "@/components/ContentType/DestinationPage/SeoFilters"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import DestinationDataProvider from "@/providers/DestinationDataProvider"
import { getPathname } from "@/utils/getPathname"
@@ -45,7 +44,6 @@ export default async function DestinationCityPage({
filterFromUrl,
}: DestinationCityPageProps) {
const intl = await getIntl()
const lang = await getLang()
const pathname = await getPathname()
const pageData = await getDestinationCityPage()
@@ -70,7 +68,7 @@ export default async function DestinationCityPage({
const activeSeoFilter = getActiveSeoFilter(seo_filters, filterFromUrl)
const allHotels = await getHotelsByCityIdentifier(cityIdentifier)
const hotelFilters = getFiltersFromHotels(allHotels, lang)
const allHotelFilters = await getHotelFilters()
const sortItems: HotelSortItem[] = [
{
@@ -102,7 +100,7 @@ export default async function DestinationCityPage({
<Suspense fallback={<DestinationCityPageSkeleton />}>
<DestinationDataProvider
allHotels={allHotels}
hotelFilters={hotelFilters}
hotelFilters={allHotelFilters}
seoFilters={seo_filters}
sortItems={sortItems}
pathname={pathname}

View File

@@ -5,12 +5,12 @@ import {
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import { getFiltersFromHotels } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { env } from "@/env/server"
import {
getDestinationCityPagesByCountry,
getDestinationCountryPage,
getHotelFilters,
getHotelsByCountry,
} from "@/lib/trpc/memoizedRequests"
@@ -18,7 +18,6 @@ import Breadcrumbs from "@/components/Breadcrumbs"
import { SeoFilters } from "@/components/ContentType/DestinationPage/SeoFilters"
import BreadcrumbsSkeleton from "@/components/TempDesignSystem/Breadcrumbs/BreadcrumbsSkeleton"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import DestinationDataProvider from "@/providers/DestinationDataProvider"
import { getPathname } from "@/utils/getPathname"
@@ -46,7 +45,6 @@ export default async function DestinationCountryPage({
filterFromUrl,
}: DestinationCountryPageProps) {
const intl = await getIntl()
const lang = await getLang()
const pathname = await getPathname()
const pageData = await getDestinationCountryPage()
@@ -74,7 +72,7 @@ export default async function DestinationCountryPage({
getHotelsByCountry(destination_settings.country),
getDestinationCityPagesByCountry(destination_settings.country),
])
const hotelFilters = getFiltersFromHotels(allHotels, lang)
const allHotelFilters = await getHotelFilters()
const sortItems: HotelSortItem[] = [
{
@@ -114,7 +112,7 @@ export default async function DestinationCountryPage({
<DestinationDataProvider
allHotels={allHotels}
allCities={allCitiesWithCount}
hotelFilters={hotelFilters}
hotelFilters={allHotelFilters}
seoFilters={seo_filters}
sortItems={sortItems}
pathname={pathname}

View File

@@ -6,36 +6,38 @@
cursor: pointer;
border-radius: var(--Corner-radius-md);
transition: background-color 0.3s;
}
.checkboxWrapper:hover {
background-color: var(--UI-Input-Controls-Surface-Hover);
@media (hover: hover) {
&:not([data-disabled]):hover {
background-color: var(--UI-Input-Controls-Surface-Hover);
}
}
&[data-selected] .checkbox {
border: none;
background-color: var(--Surface-UI-Fill-Active);
}
&[data-disabled] {
cursor: not-allowed;
.checkbox {
border-color: var(--UI-Input-Controls-Border-Disabled);
background-color: var(--UI-Input-Controls-Surface-Disabled);
}
}
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
background: var(--UI-Input-Controls-Surface-Normal);
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: var(--Corner-radius-sm);
transition: all 0.3s;
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--UI-Input-Controls-Surface-Normal);
}
.checkboxWrapper[data-selected] .checkbox {
border-color: var(--UI-Input-Controls-Fill-Selected);
background-color: var(--UI-Input-Controls-Fill-Selected);
}
@media screen and (max-width: 767px) {
.checkboxWrapper:hover {
background-color: transparent;
}
.checkboxWrapper[data-selected] {
background-color: transparent;
}
forced-color-adjust: none;
}

View File

@@ -11,6 +11,7 @@ interface CheckboxProps {
name: string
value: string
isSelected: boolean
isDisabled?: boolean
onChange: (filterId: string) => void
}
@@ -18,6 +19,7 @@ export default function Checkbox({
isSelected,
name,
value,
isDisabled,
onChange,
}: CheckboxProps) {
return (
@@ -25,6 +27,7 @@ export default function Checkbox({
className={styles.checkboxWrapper}
isSelected={isSelected}
onChange={() => onChange(value)}
isDisabled={isDisabled}
>
{({ isSelected }) => (
<>

View File

@@ -10,13 +10,17 @@ import Checkbox from "./Checkbox"
import styles from "./filter.module.css"
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
import type {
HotelFilter,
HotelFilters,
} from "@scandic-hotels/trpc/routers/hotels/filters/output"
interface FilterProps {
filters: CategorizedHotelFilters
filters: HotelFilters
listType: "city" | "hotel"
}
export default function Filter({ filters }: FilterProps) {
export default function Filter({ filters, listType }: FilterProps) {
const intl = useIntl()
const { facilityFilters, surroundingsFilters } = filters
const { pendingFilters, togglePendingFilter } = useDestinationDataStore(
@@ -54,8 +58,9 @@ export default function Filter({ filters }: FilterProps) {
{facilityFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
name={getCheckboxLabelWithCount(filter, listType)}
value={filter.slug}
isDisabled={filter.hotelCount === 0}
onChange={() => togglePendingFilter(filter)}
isSelected={
!!pendingFilters.find((pf) => pf.id === filter.id)
@@ -79,8 +84,9 @@ export default function Filter({ filters }: FilterProps) {
{surroundingsFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
name={getCheckboxLabelWithCount(filter, listType)}
value={filter.slug}
isDisabled={filter.hotelCount === 0}
onChange={() => togglePendingFilter(filter)}
isSelected={
!!pendingFilters.find((pf) => pf.id === filter.id)
@@ -94,3 +100,17 @@ export default function Filter({ filters }: FilterProps) {
</div>
)
}
function getCheckboxLabelWithCount(
filter: HotelFilter,
listType: "city" | "hotel"
) {
if (listType === "city" && filter.cityCount) {
return `${filter.name} (${filter.cityCount})`
}
if (listType === "hotel" && filter.hotelCount) {
return `${filter.name} (${filter.hotelCount})`
}
return filter.name
}

View File

@@ -34,7 +34,7 @@ export default function DestinationFilterAndSort({
const intl = useIntl()
const router = useRouter()
const {
allFilters,
filtersWithCount,
sortItems,
pendingFilters,
pendingSort,
@@ -47,7 +47,7 @@ export default function DestinationFilterAndSort({
resetPendingValues,
setIsLoading,
} = useDestinationDataStore((state) => ({
allFilters: state.allFilters,
filtersWithCount: state.filtersWithCount,
sortItems: state.sortItems,
pendingFilters: state.pendingFilters,
pendingSort: state.pendingSort,
@@ -166,7 +166,7 @@ export default function DestinationFilterAndSort({
<div className={styles.content}>
<Sort sortItems={sortItems} />
<Divider className={styles.divider} />
<Filter filters={allFilters} />
<Filter filters={filtersWithCount} listType={listType} />
</div>
{pendingCount === 0 && (
<div className={styles.alertWrapper}>

View File

@@ -6,36 +6,38 @@
cursor: pointer;
border-radius: var(--Corner-radius-md);
transition: background-color 0.3s;
}
.checkboxWrapper:hover {
background-color: var(--UI-Input-Controls-Surface-Hover);
@media (hover: hover) {
&:not([data-disabled]):hover {
background-color: var(--UI-Input-Controls-Surface-Hover);
}
}
&[data-selected] .checkbox {
border: none;
background-color: var(--Surface-UI-Fill-Active);
}
&[data-disabled] {
cursor: not-allowed;
.checkbox {
border-color: var(--UI-Input-Controls-Border-Disabled);
background-color: var(--UI-Input-Controls-Surface-Disabled);
}
}
}
.checkbox {
width: 24px;
height: 24px;
min-width: 24px;
background: var(--UI-Input-Controls-Surface-Normal);
border: 1px solid var(--UI-Input-Controls-Border-Normal);
border-radius: var(--Corner-radius-sm);
transition: all 0.3s;
border-radius: 4px;
transition: all 200ms;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--UI-Input-Controls-Surface-Normal);
}
.checkboxWrapper[data-selected] .checkbox {
border-color: var(--UI-Input-Controls-Fill-Selected);
background-color: var(--UI-Input-Controls-Fill-Selected);
}
@media screen and (max-width: 767px) {
.checkboxWrapper:hover {
background-color: transparent;
}
.checkboxWrapper[data-selected] {
background-color: transparent;
}
forced-color-adjust: none;
}

View File

@@ -11,6 +11,7 @@ interface CheckboxProps {
name: string
value: string
isSelected: boolean
isDisabled?: boolean
onChange: (filterId: string) => void
}
@@ -18,12 +19,14 @@ export default function Checkbox({
isSelected,
name,
value,
isDisabled,
onChange,
}: CheckboxProps) {
return (
<AriaCheckbox
className={styles.checkboxWrapper}
isSelected={isSelected}
isDisabled={isDisabled}
onChange={() => onChange(value)}
>
{({ isSelected }) => (

View File

@@ -10,10 +10,10 @@ import Checkbox from "./Checkbox"
import styles from "./filter.module.css"
import type { CategorizedHotelFilters } from "@scandic-hotels/trpc/types/hotel"
import type { HotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/output"
interface FilterProps {
filters: CategorizedHotelFilters
filters: HotelFilters
}
export default function Filter({ filters }: FilterProps) {
@@ -58,10 +58,17 @@ export default function Filter({ filters }: FilterProps) {
{countryFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
name={
filter.hotelCount
? `${filter.name} (${filter.hotelCount})`
: filter.name
}
isDisabled={!filter.hotelCount}
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>
))}
@@ -81,10 +88,17 @@ export default function Filter({ filters }: FilterProps) {
{facilityFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
name={
filter.hotelCount
? `${filter.name} (${filter.hotelCount})`
: filter.name
}
isDisabled={!filter.hotelCount}
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>
))}
@@ -104,10 +118,17 @@ export default function Filter({ filters }: FilterProps) {
{surroundingsFilters.map((filter) => (
<li key={`filter-${filter.slug}`}>
<Checkbox
name={filter.name}
name={
filter.hotelCount
? `${filter.name} (${filter.hotelCount})`
: filter.name
}
isDisabled={!filter.hotelCount}
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

@@ -28,7 +28,7 @@ export default function HotelFilterAndSort() {
const intl = useIntl()
const router = useRouter()
const {
filters,
filtersWithCount,
sortItems,
pendingFilters,
pendingSort,
@@ -39,7 +39,7 @@ export default function HotelFilterAndSort() {
resetPendingValues,
setIsLoading,
} = useHotelListingDataStore((state) => ({
filters: state.allFilters,
filtersWithCount: state.filtersWithCount,
sortItems: state.sortItems,
pendingFilters: state.pendingFilters,
pendingSort: state.pendingSort,
@@ -135,7 +135,7 @@ export default function HotelFilterAndSort() {
<div className={styles.content}>
<Sort sortItems={sortItems} />
<Divider className={styles.divider} />
<Filter filters={filters} />
<Filter filters={filtersWithCount} />
</div>
{pendingCount === 0 && (
<div className={styles.alertWrapper}>

View File

@@ -203,6 +203,12 @@ export const getHotelsByCityIdentifier = cache(
})
}
)
export const getHotelFilters = cache(async function getMemoizedHotelFilters() {
const caller = await serverClient()
return caller.hotel.filters.get()
})
export const getAllHotelData = cache(async function getMemoizedAllHotelData() {
const caller = await serverClient()
return caller.hotel.hotels.getAllHotelData()

View File

@@ -8,8 +8,25 @@ import { DestinationDataContext } from "@/contexts/DestinationData"
import DestinationDataProviderContent from "./Content"
import type { HotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { DestinationFilters } from "@scandic-hotels/trpc/types/destinationsData"
import type {
HotelListingHotelData,
HotelSortItem,
} from "@scandic-hotels/trpc/types/hotel"
import type { DestinationDataStore } from "@/types/contexts/destination-data"
import type { DestinationDataProviderProps } from "@/types/providers/destination-data"
interface DestinationDataProviderProps extends React.PropsWithChildren {
allHotels: HotelListingHotelData[]
allCities?: DestinationCityListItem[]
hotelFilters: HotelFilters
seoFilters: DestinationFilters
filterFromUrl?: string
sortItems: HotelSortItem[]
pathname: string
}
export default function DestinationDataProvider({
allCities = [],

View File

@@ -1,10 +1,13 @@
import {
type HotelFilter,
type HotelListingHotelData,
type HotelSortItem,
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import type {
HotelFilter,
HotelFilters,
} from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { DestinationFilter } from "@scandic-hotels/trpc/types/destinationsData"
@@ -32,7 +35,9 @@ export function getFilteredHotels(
if (filters.length) {
return hotels.filter(({ hotel }) =>
filters.every((filter) =>
hotel.detailedFacilities.some((facility) => facility.id === filter.id)
hotel.detailedFacilities.some(
(facility) => facility.id === Number(filter.id)
)
)
)
}
@@ -96,3 +101,81 @@ export function getActiveDestinationFilter(
}
return allSeoFilters.find((f) => f.filter.id === filterFromUrl.id) || null
}
export function getFiltersWithHotelAndCityCount(
hotelFilters: HotelFilters,
hotels: HotelListingHotelData[],
cities: DestinationCityListItem[]
): HotelFilters {
const flattenedFilters = Object.values(hotelFilters).flat()
const filterHotelCounts: Record<string, number> = {}
const filterCityIdentifiers: Record<string, Set<string>> = {}
hotels.forEach(({ hotel }) => {
hotel.detailedFacilities.forEach((facility) => {
filterHotelCounts[facility.id] = (filterHotelCounts[facility.id] || 0) + 1
if (!filterCityIdentifiers[facility.id]) {
filterCityIdentifiers[facility.id] = new Set()
}
if (hotel.cityIdentifier) {
filterCityIdentifiers[facility.id].add(hotel.cityIdentifier)
}
})
if (hotel.countryCode) {
filterHotelCounts[hotel.countryCode] =
(filterHotelCounts[hotel.countryCode] || 0) + 1
if (!filterCityIdentifiers[hotel.countryCode]) {
filterCityIdentifiers[hotel.countryCode] = new Set()
}
if (hotel.cityIdentifier) {
filterCityIdentifiers[hotel.countryCode].add(hotel.cityIdentifier)
}
}
})
// Count cities that match the cityIdentifiers for each filter
const filterCityCounts: Record<string, number> = {}
Object.entries(filterCityIdentifiers).forEach(
([filterId, cityIdentifiers]) => {
filterCityCounts[filterId] = cities.filter(
(city) =>
city.destination_settings.city &&
cityIdentifiers.has(city.destination_settings.city)
).length
}
)
return flattenedFilters.reduce(
(acc, filter) => {
if (filter.filterType === "facility") {
acc.facilityFilters.push({
...filter,
hotelCount: filterHotelCounts[filter.id] ?? 0,
cityCount: filterCityCounts[filter.id] ?? 0,
})
} else if (filter.filterType === "surroundings") {
acc.surroundingsFilters.push({
...filter,
hotelCount: filterHotelCounts[filter.id] ?? 0,
cityCount: filterCityCounts[filter.id] ?? 0,
})
} else if (filter.filterType === "country") {
acc.countryFilters.push({
...filter,
filterType: "country",
hotelCount: filterHotelCounts[filter.id] ?? 0,
cityCount: filterCityCounts[filter.id] ?? 0,
})
}
return acc
},
{
facilityFilters: [],
surroundingsFilters: [],
countryFilters: [],
} as HotelFilters
)
}

View File

@@ -2,8 +2,8 @@ import { produce } from "immer"
import { useContext } from "react"
import { create, useStore } from "zustand"
import { mergeHotelFiltersAndSeoFilters } from "@scandic-hotels/trpc/utils/getFiltersFromHotels"
import { getSortedCities } from "@scandic-hotels/trpc/utils/getSortedCities"
import { mergeHotelFiltersAndSeoFilters } from "@scandic-hotels/trpc/utils/mergeHotelFiltersAndSeoFilters"
import { DestinationDataContext } from "@/contexts/DestinationData"
import {
@@ -16,12 +16,13 @@ import {
getBasePathNameWithoutFilters,
getFilteredCities,
getFilteredHotels,
getFiltersWithHotelAndCityCount,
getSortedHotels,
isValidSortOption,
} from "./helper"
import type { HotelFilter } from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type { DestinationFilter } from "@scandic-hotels/trpc/types/destinationsData"
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
import type {
DestinationDataState,
@@ -39,9 +40,10 @@ export function createDestinationDataStore({
}: InitialState) {
const defaultSort =
sortItems.find((s) => s.isDefault)?.value ?? sortItems[0].value
const allFilters = mergeHotelFiltersAndSeoFilters(hotelFilters, seoFilters)
const allSeoFilters = Object.values(seoFilters).flat()
const allFlattenedFilters = Object.values(allFilters).flat<HotelFilter[]>()
const allFlattenedFilters = Object.values(allFilters).flat()
const allFilterSlugs = allFlattenedFilters.map((filter) => filter.slug)
const activeFilters: HotelFilter[] = []
let activeSeoFilter: DestinationFilter | null = null
@@ -75,6 +77,12 @@ export function createDestinationDataStore({
const filteredCities = getFilteredCities(filteredHotels, allCities)
const activeCities = getSortedCities(filteredCities, activeSort)
const filtersWithCount = getFiltersWithHotelAndCityCount(
allFilters,
filteredHotels,
filteredCities
)
return create<DestinationDataState>((set) => ({
actions: {
updateActiveFiltersAndSort(filterSlugs, sort, filterSlugFromUrl) {
@@ -175,9 +183,16 @@ export function createDestinationDataStore({
? getFilteredCities(pendingHotels, state.allCities)
: []
const pendingFiltersWithCount = getFiltersWithHotelAndCityCount(
state.allFilters,
pendingHotels,
pendingCities
)
state.pendingFilters = filters
state.pendingHotelCount = pendingHotels.length
state.pendingCityCount = pendingCities.length
state.filtersWithCount = pendingFiltersWithCount
})
)
},
@@ -187,6 +202,11 @@ export function createDestinationDataStore({
state.pendingFilters = []
state.pendingHotelCount = state.allHotels.length
state.pendingCityCount = state.allCities.length
state.filtersWithCount = getFiltersWithHotelAndCityCount(
state.allFilters,
state.allHotels,
state.allCities
)
})
)
},
@@ -197,6 +217,19 @@ export function createDestinationDataStore({
state.pendingSort = state.activeSort
state.pendingHotelCount = state.activeHotels.length
state.pendingCityCount = state.activeCities.length
const filteredHotels = getFilteredHotels(
state.allHotels,
state.activeFilters
)
const filteredCities = getFilteredCities(
filteredHotels,
state.allCities
)
state.filtersWithCount = getFiltersWithHotelAndCityCount(
state.allFilters,
filteredHotels,
filteredCities
)
})
)
},
@@ -213,6 +246,7 @@ export function createDestinationDataStore({
activeFilters,
pendingFilters: activeFilters,
allFilters,
filtersWithCount,
activeSeoFilter,
filterFromUrl,
basePathnameWithoutFilters,

View File

@@ -4,6 +4,11 @@ import {
HotelSortOption,
} from "@scandic-hotels/trpc/types/hotel"
import type {
HotelFilter,
HotelFilters,
} from "@scandic-hotels/trpc/routers/hotels/filters/output"
const HOTEL_SORTING_STRATEGIES: Partial<
Record<
HotelSortOption,
@@ -20,16 +25,15 @@ const HOTEL_SORTING_STRATEGIES: Partial<
export function getFilteredHotels(
hotels: HotelListingHotelData[],
filters: string[]
filters: HotelFilter[]
) {
if (filters.length) {
return hotels.filter(({ hotel }) =>
filters.every((filter) => {
const matchesFacility = hotel.detailedFacilities.some(
(facility) => facility.slug === filter
(facility) => facility.id === Number(filter.id)
)
const matchesCountry =
hotel.countryCode.toLowerCase() === filter.toLowerCase()
const matchesCountry = hotel.countryCode === filter.id
return matchesFacility || matchesCountry
})
)
@@ -51,3 +55,50 @@ export function isValidSortOption(
): value is HotelSortOption {
return sortItems.map((item) => item.value).includes(value as HotelSortOption)
}
export function getFiltersWithHotelCount(
hotelFilters: HotelFilters,
hotels: HotelListingHotelData[]
): HotelFilters {
const flattenedFilters = Object.values(hotelFilters).flat()
const filterHotelCounts: Record<string, number> = {}
hotels.forEach(({ hotel }) => {
hotel.detailedFacilities.forEach((facility) => {
filterHotelCounts[facility.id] = (filterHotelCounts[facility.id] || 0) + 1
})
if (hotel.countryCode) {
filterHotelCounts[hotel.countryCode] =
(filterHotelCounts[hotel.countryCode] || 0) + 1
}
})
return flattenedFilters.reduce(
(acc, filter) => {
if (filter.filterType === "facility") {
acc.facilityFilters.push({
...filter,
hotelCount: filterHotelCounts[filter.id] ?? 0,
})
} else if (filter.filterType === "surroundings") {
acc.surroundingsFilters.push({
...filter,
hotelCount: filterHotelCounts[filter.id] ?? 0,
})
} else if (filter.filterType === "country") {
acc.countryFilters.push({
...filter,
filterType: "country",
hotelCount: filterHotelCounts[filter.id] ?? 0,
})
}
return acc
},
{
facilityFilters: [],
surroundingsFilters: [],
countryFilters: [],
} as HotelFilters
)
}

View File

@@ -8,9 +8,14 @@ import {
trackSortingChangeEvent,
} from "@/utils/tracking/destinationPage"
import { getFilteredHotels, getSortedHotels, isValidSortOption } from "./helper"
import {
getFilteredHotels,
getFiltersWithHotelCount,
getSortedHotels,
isValidSortOption,
} from "./helper"
import type { HotelFilter } from "@scandic-hotels/trpc/types/hotel"
import type { HotelFilter } from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type {
HotelListingDataState,
@@ -25,11 +30,10 @@ export function createHotelListingDataStore({
}: 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 allFlattenedFilters = Object.values(allFilters).flat()
const allFilterSlugs = allFlattenedFilters.map((filter) => filter.slug)
const activeFilters: string[] = []
const activeFilters: HotelFilter[] = []
let activeSort = defaultSort
if (searchParams) {
@@ -43,8 +47,9 @@ export function createHotelListingDataStore({
if (filterParam) {
const filters = filterParam.split(",")
filters.forEach((filter) => {
if (allFilterSlugs.includes(filter)) {
activeFilters.push(filter)
const filterFromUrl = allFlattenedFilters.find((f) => f.slug === filter)
if (filterFromUrl) {
activeFilters.push(filterFromUrl)
}
})
}
@@ -52,15 +57,20 @@ export function createHotelListingDataStore({
const filteredHotels = getFilteredHotels(allHotels, activeFilters)
const activeHotels = getSortedHotels(filteredHotels, activeSort)
const filtersWithCount = getFiltersWithHotelCount(allFilters, filteredHotels)
return create<HotelListingDataState>((set) => ({
actions: {
updateActiveFiltersAndSort(filters, sort) {
updateActiveFiltersAndSort(filterSlugs, sort) {
return set(
produce((state: HotelListingDataState) => {
const newSort =
sort && isValidSortOption(sort, state.sortItems)
? sort
: state.defaultSort
const filters = allFlattenedFilters.filter((filter) =>
filterSlugs.includes(filter.slug)
)
const filteredHotels = getFilteredHotels(state.allHotels, filters)
const sortedHotels = getSortedHotels(filteredHotels, newSort)
@@ -71,16 +81,22 @@ export function createHotelListingDataStore({
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,
@@ -116,14 +132,23 @@ export function createHotelListingDataStore({
togglePendingFilter(filter) {
return set(
produce((state: HotelListingDataState) => {
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((pf) => pf.id !== filterId)
: [...state.pendingFilters, filter]
const pendingHotels = getFilteredHotels(state.allHotels, filters)
const pendingFiltersWithCount = getFiltersWithHotelCount(
state.allFilters,
pendingHotels
)
state.pendingFilters = filters
state.pendingHotelCount = pendingHotels.length
state.filtersWithCount = pendingFiltersWithCount
})
)
},
@@ -132,6 +157,10 @@ export function createHotelListingDataStore({
produce((state: HotelListingDataState) => {
state.pendingFilters = []
state.pendingHotelCount = state.allHotels.length
state.filtersWithCount = getFiltersWithHotelCount(
state.allFilters,
state.allHotels
)
})
)
},
@@ -141,6 +170,10 @@ export function createHotelListingDataStore({
state.pendingFilters = state.activeFilters
state.pendingSort = state.activeSort
state.pendingHotelCount = state.activeHotels.length
state.filtersWithCount = getFiltersWithHotelCount(
state.allFilters,
state.activeHotels
)
})
)
},
@@ -154,6 +187,7 @@ export function createHotelListingDataStore({
activeFilters,
pendingFilters: activeFilters,
allFilters,
filtersWithCount,
allFilterSlugs,
sortItems,
isLoading: false,

View File

@@ -1,17 +0,0 @@
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type { DestinationFilters } from "@scandic-hotels/trpc/types/destinationsData"
import type {
CategorizedHotelFilters,
HotelListingHotelData,
HotelSortItem,
} from "@scandic-hotels/trpc/types/hotel"
export interface DestinationDataProviderProps extends React.PropsWithChildren {
allHotels: HotelListingHotelData[]
allCities?: DestinationCityListItem[]
hotelFilters: CategorizedHotelFilters
seoFilters: DestinationFilters
filterFromUrl?: string
sortItems: HotelSortItem[]
pathname: string
}

View File

@@ -1,11 +1,11 @@
import type { HotelFilters } from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type {
CategorizedHotelFilters,
HotelListingHotelData,
HotelSortItem,
} from "@scandic-hotels/trpc/types/hotel"
export interface HotelListingDataProviderProps extends React.PropsWithChildren {
allHotels: HotelListingHotelData[]
allFilters: CategorizedHotelFilters
allFilters: HotelFilters
sortItems: HotelSortItem[]
}

View File

@@ -1,11 +1,13 @@
import type {
HotelFilter,
HotelFilters,
} from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type { DestinationCityListItem } from "@scandic-hotels/trpc/types/destinationCityPage"
import type {
DestinationFilter,
DestinationFilters,
} from "@scandic-hotels/trpc/types/destinationsData"
import type {
CategorizedHotelFilters,
HotelFilter,
HotelListingHotelData,
HotelSortItem,
HotelSortOption,
@@ -39,17 +41,20 @@ export interface DestinationDataState {
filterFromUrl: HotelFilter | null
pendingHotelCount: number
pendingCityCount: number
allFilters: CategorizedHotelFilters
allFilters: HotelFilters
filtersWithCount: HotelFilters
activeSeoFilter: DestinationFilter | null
basePathnameWithoutFilters: string
sortItems: HotelSortItem[]
isLoading: boolean
}
export interface InitialState
extends Pick<DestinationDataState, "allHotels" | "allCities" | "sortItems"> {
export interface InitialState extends Pick<
DestinationDataState,
"allHotels" | "allCities" | "sortItems"
> {
pathname: string
searchParams: ReadonlyURLSearchParams
hotelFilters: CategorizedHotelFilters
hotelFilters: HotelFilters
seoFilters: DestinationFilters
}

View File

@@ -1,5 +1,8 @@
import type {
CategorizedHotelFilters,
HotelFilter,
HotelFilters,
} from "@scandic-hotels/trpc/routers/hotels/filters/output"
import type {
HotelListingHotelData,
HotelSortItem,
HotelSortOption,
@@ -9,7 +12,7 @@ import type { ReadonlyURLSearchParams } from "next/navigation"
interface Actions {
updateActiveFiltersAndSort: (filters: 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
@@ -22,19 +25,19 @@ export interface HotelListingDataState {
pendingSort: HotelSortOption
activeSort: HotelSortOption
defaultSort: HotelSortOption
pendingFilters: string[]
activeFilters: string[]
pendingFilters: HotelFilter[]
activeFilters: HotelFilter[]
pendingHotelCount: number
allFilters: CategorizedHotelFilters
allFilters: HotelFilters
filtersWithCount: HotelFilters
allFilterSlugs: string[]
sortItems: HotelSortItem[]
isLoading: boolean
}
export interface InitialState
extends Pick<
HotelListingDataState,
"allHotels" | "sortItems" | "allFilters"
> {
export interface InitialState extends Pick<
HotelListingDataState,
"allHotels" | "sortItems" | "allFilters"
> {
searchParams: ReadonlyURLSearchParams
}