Feat/SW-2271 hotel list filtering

* feat(SW-2271): Changes to hotel data types in preperation for filtering
* feat(SW-2271): Added filter and sort functionality

Approved-by: Matilda Landström
This commit is contained in:
Erik Tiekstra
2025-07-04 09:27:20 +00:00
parent 82e21af0d4
commit fa7214cb58
58 changed files with 1572 additions and 450 deletions

View File

@@ -1,6 +0,0 @@
export enum SortOption {
Recommended = "recommended",
Distance = "distance",
Name = "name",
TripAdvisorRating = "tripadvisor",
}

View File

@@ -1,67 +0,0 @@
import type {
CategorizedFilters,
Filter,
} from "../../../types/destinationFilterAndSort"
import type { DestinationPagesHotelData } from "../../../types/hotel"
const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
export function getFiltersFromHotels(
hotels: DestinationPagesHotelData[]
): CategorizedFilters {
if (hotels.length === 0) {
return { facilityFilters: [], surroundingsFilters: [] }
}
const filters = hotels.flatMap(({ hotel }) => hotel.detailedFacilities)
const uniqueFilterNames = [...new Set(filters.map((filter) => filter.name))]
const filterList = uniqueFilterNames
.map((filterName) => {
const filter = filters.find((filter) => filter.name === filterName)
return filter
? {
name: filter.name,
slug: filter.slug,
filterType: filter.filter,
sortOrder: filter.sortOrder,
}
: null
})
.filter((filter): filter is Filter => !!filter)
const facilityFilters = filterList.filter((filter) =>
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
)
const surroundingsFilters = filterList.filter((filter) =>
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
)
return {
facilityFilters: sortFilters(facilityFilters),
surroundingsFilters: sortFilters(surroundingsFilters),
}
}
function sortFilters(filters: Filter[]): Filter[] {
return [...filters].sort((a, b) => {
// First sort by sortOrder
const orderDiff = a.sortOrder - b.sortOrder
// If sortOrder is the same, sort by name as secondary criterion
return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff
})
}

View File

@@ -1,5 +1,6 @@
import { SortOption } from "../../../enums/destinationFilterAndSort"
import { ApiCountry } from "../../../types/country"
import { HotelSortOption } from "../../../types/hotel"
import { getFiltersFromHotels } from "../../../utils/getFiltersFromHotels"
import { getSortedCities } from "../../../utils/getSortedCities"
import {
getCityByCityIdentifier,
@@ -8,7 +9,6 @@ import {
getHotelsByHotelIds,
} from "../../hotels/utils"
import { getCityPages } from "../destinationCountryPage/utils"
import { getFiltersFromHotels } from "./helpers"
import type { Lang } from "@scandic-hotels/common/constants/language"
@@ -61,7 +61,7 @@ export async function getCityData(
let filterType
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const allFilters = getFiltersFromHotels(hotels, lang)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)
@@ -101,7 +101,7 @@ export async function getCountryData(
let filterType
const cities = await getCityPages(lang, serviceToken, country)
const sortedCities = getSortedCities(cities, SortOption.Recommended)
const sortedCities = getSortedCities(cities, HotelSortOption.Recommended)
const hotelIds = await getHotelIdsByCountry({
country,
serviceToken,
@@ -110,7 +110,7 @@ export async function getCountryData(
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
if (filter) {
const allFilters = getFiltersFromHotels(hotels)
const allFilters = getFiltersFromHotels(hotels, lang)
const facilityFilter = allFilters.facilityFilters.find(
(f) => f.slug === filter
)

View File

@@ -239,9 +239,13 @@ export const getHotelsByCSFilterInput = z.object({
})
.nullish(),
hotelsToInclude: z.array(z.string()),
contentType: z
.enum(["hotel", "restaurant", "meeting"])
.optional()
.default("hotel"),
})
export interface GetHotelsByCSFilterInput
extends z.infer<typeof getHotelsByCSFilterInput> {}
extends z.input<typeof getHotelsByCSFilterInput> {}
export const nearbyHotelIdsInput = z.object({
hotelId: z.string(),

View File

@@ -616,40 +616,24 @@ export const roomFeaturesSchema = z
return data.data.attributes.roomFeatures
})
export const destinationPagesHotelDataSchema = z
.object({
data: z.object({
id: z.string(),
name: z.string(),
location: locationSchema,
cityIdentifier: z.string().optional(),
tripadvisor: z.number().optional(),
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
address: addressSchema,
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
url: z.string().optional(),
hotelContent: z
.object({
texts: z.object({
descriptions: z.object({
short: z.string().optional(),
}),
}),
})
.optional(),
}),
})
.transform(({ data: { hotelContent, ...data } }) => {
return {
hotel: {
...data,
hotelDescription: hotelContent?.texts.descriptions?.short,
},
url: data.url ?? "",
}
})
export const hotelListingHotelDataSchema = z.object({
hotel: z.object({
id: z.string(),
name: z.string(),
countryCode: z.string(),
location: locationSchema,
cityIdentifier: z.string().nullable(),
tripadvisor: z.number().nullable(),
detailedFacilities: detailedFacilitiesSchema,
galleryImages: z
.array(imageSchema)
.nullish()
.transform((arr) => (arr ? arr.filter(Boolean) : [])),
address: addressSchema,
hotelType: z.string(),
type: z.literal("hotels"), // No enum here but the standard return appears to be "hotels".
description: z.string().nullable(),
}),
url: z.string().nullable(),
meetingUrl: z.string().nullable(),
})

View File

@@ -53,19 +53,17 @@ import { getVerifiedUser } from "../user/utils"
import { additionalDataSchema } from "./schemas/hotel/include/additionalData"
import { meetingRoomsSchema } from "./schemas/meetingRoom"
import {
getBedTypes,
getCitiesByCountry,
getCountries,
getHotel,
getHotelIdsByCityId,
getHotelIdsByCityIdentifier,
getHotelIdsByCountry,
getHotelsByHotelIds,
getLocations,
} from "./utils"
import {
getBedTypes,
getHotelsAvailabilityByCity,
getHotelsAvailabilityByHotelIds,
getHotelsByHotelIds,
getLocations,
getPackages,
getRoomsAvailability,
getSelectedRoomAvailability,
@@ -73,10 +71,7 @@ import {
selectRateRedirectURL,
} from "./utils"
import type {
DestinationPagesHotelData,
HotelDataWithUrl,
} from "../../types/hotel"
import type { HotelListingHotelData } from "../../types/hotel"
import type { CityLocation } from "../../types/locations"
import type { Room } from "../../types/room"
@@ -570,7 +565,7 @@ export const hotelQueryRouter = router({
get: contentStackBaseWithServiceProcedure
.input(getHotelsByCSFilterInput)
.query(async function ({ ctx, input }) {
const { locationFilter, hotelsToInclude } = input
const { locationFilter, hotelsToInclude, contentType } = input
const language = ctx.lang
let hotelsToFetch: string[] = []
@@ -669,29 +664,16 @@ export const hotelQueryRouter = router({
return []
}
const hotelPages = await getHotelPageUrls(language)
const hotels = await Promise.all(
hotelsToFetch.map(async (hotelId) => {
const hotelData = await getHotel(
{ hotelId, isCardOnlyPayment: false, language },
ctx.serviceToken
)
const hotelPage = hotelPages.find(
(page) => page.hotelId === hotelId
)
return hotelData
? {
...hotelData,
url: hotelPage?.url ?? null,
}
: null
})
)
const hotels = await getHotelsByHotelIds({
hotelIds: hotelsToFetch,
lang: language,
serviceToken: ctx.serviceToken,
contentType,
})
metricsGetHotelsByCSFilter.success()
return hotels.filter((hotel): hotel is HotelDataWithUrl => !!hotel)
return hotels
}),
}),
getDestinationsMapData: serviceProcedure
@@ -713,7 +695,7 @@ export const hotelQueryRouter = router({
}
const countryNames = countries.data.map((country) => country.name)
const hotelData: DestinationPagesHotelData[] = (
const hotelData: HotelListingHotelData[] = (
await Promise.all(
countryNames.map(async (country) => {
const hotelIds = await getHotelIdsByCountry({

View File

@@ -48,8 +48,11 @@ import type {
RoomsAvailabilityOutputSchema,
} from "../../types/availability"
import type { BedTypeSelection } from "../../types/bedTypeSelection"
import type { Room as RoomCategory } from "../../types/hotel"
import type { DestinationPagesHotelData, HotelInput } from "../../types/hotel"
import type {
HotelInput,
HotelListingHotelData,
Room as RoomCategory,
} from "../../types/hotel"
import type {
CitiesGroupedByCountry,
CityLocation,
@@ -348,13 +351,15 @@ export async function getHotelsByHotelIds({
hotelIds,
lang,
serviceToken,
contentType = "hotel",
}: {
hotelIds: string[]
lang: Lang
serviceToken: string
contentType?: "hotel" | "restaurant" | "meeting"
}) {
const cacheClient = await getCacheClient()
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${contentType}:${hotelIds.sort().join(",")}`
return await cacheClient.cacheOrGet(
cacheKey,
@@ -362,7 +367,7 @@ export async function getHotelsByHotelIds({
const hotelPages = await getHotelPageUrls(lang)
const chunkedHotelIds = chunk(hotelIds, 10)
const hotels: DestinationPagesHotelData[] = []
const hotels: HotelListingHotelData[] = []
for (const hotelIdChunk of chunkedHotelIds) {
const chunkedHotels = await Promise.all(
hotelIdChunk.map(async (hotelId) => {
@@ -378,22 +383,59 @@ export async function getHotelsByHotelIds({
const hotelPage = hotelPages.find(
(page) => page.hotelId === hotelId
)
const { hotel, cities } = hotelResponse
const data: DestinationPagesHotelData = {
const { hotel, cities, additionalData } = hotelResponse
const content = {
description: hotel.hotelContent?.texts.descriptions?.short,
galleryImages: hotel.galleryImages,
url: hotelPage?.url ?? "",
openInNewTab: false,
}
if (contentType === "restaurant") {
const restaurantDescription =
additionalData?.restaurantsOverviewPage
.restaurantsContentDescriptionShort
const restaurantImages =
additionalData.restaurantImages?.heroImages
if (restaurantDescription) {
content.description = restaurantDescription
}
if (restaurantImages && restaurantImages.length > 0) {
content.galleryImages = restaurantImages
}
} else if (contentType === "meeting") {
const meetingDescription =
hotel.hotelContent.texts.meetingDescription?.short
const meetingImages =
additionalData?.conferencesAndMeetings?.heroImages
if (meetingDescription) {
content.description = meetingDescription
}
if (meetingImages && meetingImages.length > 0) {
content.galleryImages = meetingImages
}
}
const data: HotelListingHotelData = {
hotel: {
id: hotel.id,
galleryImages: hotel.galleryImages,
countryCode: hotel.countryCode,
galleryImages: content.galleryImages,
name: hotel.name,
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
detailedFacilities: hotel.detailedFacilities || [],
tripadvisor: hotel.ratings?.tripAdvisor?.rating || null,
detailedFacilities: hotel.detailedFacilities.sort(
(a, b) => b.sortOrder - a.sortOrder
),
location: hotel.location,
hotelType: hotel.hotelType,
type: hotel.type,
address: hotel.address,
cityIdentifier: cities?.[0]?.cityIdentifier,
hotelDescription: hotel.hotelContent?.texts.descriptions?.short,
cityIdentifier: cities[0]?.cityIdentifier || null,
description: content.description || null,
},
url: hotelPage?.url ?? "",
url: content.url,
meetingUrl: additionalData.meetingRooms.meetingOnlineLink || null,
}
return data
@@ -402,9 +444,7 @@ export async function getHotelsByHotelIds({
hotels.push(...chunkedHotels)
}
return hotels.filter(
(hotel): hotel is DestinationPagesHotelData => !!hotel
)
return hotels.filter((hotel): hotel is HotelListingHotelData => !!hotel)
},
"1d"
)

View File

@@ -1,11 +0,0 @@
export interface Filter {
name: string
slug: string
filterType: string
sortOrder: number
}
export interface CategorizedFilters {
facilityFilters: Filter[]
surroundingsFilters: Filter[]
}

View File

@@ -5,7 +5,7 @@ import type {
hotelInputSchema,
} from "../routers/hotels/input"
import type {
destinationPagesHotelDataSchema,
hotelListingHotelDataSchema,
hotelSchema,
} from "../routers/hotels/output"
import type { citySchema } from "../routers/hotels/schemas/city"
@@ -79,11 +79,35 @@ export type ExtraPageSchema = z.output<typeof extraPageSchema>
export type HotelDataWithUrl = HotelData & { url: string }
export type DestinationPagesHotelData = z.output<
typeof destinationPagesHotelDataSchema
>
export type HotelListingHotelData = z.output<typeof hotelListingHotelDataSchema>
export type CityCoordinatesInput = z.input<typeof cityCoordinatesInputSchema>
export type HotelInput = z.input<typeof hotelInputSchema>
export type RoomType = Pick<Room, "roomTypes" | "name">
export interface HotelFilter {
name: string
slug: string
filterType: string
sortOrder: number
}
export interface CategorizedHotelFilters {
facilityFilters: HotelFilter[]
surroundingsFilters: HotelFilter[]
countryFilters: HotelFilter[]
}
export enum HotelSortOption {
Recommended = "recommended",
Distance = "distance",
Name = "name",
TripAdvisorRating = "tripadvisor",
}
export interface HotelSortItem {
label: string
value: HotelSortOption
isDefault?: boolean
}

View File

@@ -0,0 +1,110 @@
import type { Lang } from "@scandic-hotels/common/constants/language"
import type {
CategorizedHotelFilters,
HotelFilter,
HotelListingHotelData,
} from "../types/hotel"
const HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES = [
"Hotel surroundings",
"Hotel omgivelser",
"Hotelumgebung",
"Hotellia lähellä",
"Hotellomgivelser",
"Omgivningar",
]
const HOTEL_FACILITIES_FILTER_TYPE_NAMES = [
"Hotel facilities",
"Hotellfaciliteter",
"Hotelfaciliteter",
"Hotel faciliteter",
"Hotel-Infos",
"Hotellin palvelut",
]
function sortFilters(filters: HotelFilter[]): HotelFilter[] {
return [...filters].sort((a, b) => {
// First sort by sortOrder
const orderDiff = a.sortOrder - b.sortOrder
// If sortOrder is the same, sort by name as secondary criterion
return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff
})
}
export function getFiltersFromHotels(
hotels: HotelListingHotelData[],
lang: Lang
): CategorizedHotelFilters {
if (hotels.length === 0) {
return { facilityFilters: [], surroundingsFilters: [], countryFilters: [] }
}
const uniqueCountries = new Set(
hotels.flatMap(({ hotel }) => hotel.countryCode)
)
const countryFilters = [...uniqueCountries]
.map((countryCode) => {
const localizedCountry = getLocalizedCountryByCountryCode(
countryCode,
lang
)
return localizedCountry
? {
name: localizedCountry,
slug: countryCode.toLowerCase(),
filterType: "Country",
sortOrder: 0,
}
: null
})
.filter((filter): filter is HotelFilter => !!filter)
const flattenedFacilityFilters = hotels.flatMap(
({ hotel }) => hotel.detailedFacilities
)
const uniqueFacilityFilterNames = [
...new Set(flattenedFacilityFilters.map((filter) => filter.name)),
]
const facilityFilterList = uniqueFacilityFilterNames
.map((filterName) => {
const filter = flattenedFacilityFilters.find(
(filter) => filter.name === filterName
)
return filter
? {
name: filter.name,
slug: filter.slug,
filterType: filter.filter,
sortOrder: filter.sortOrder,
}
: null
})
.filter((filter): filter is HotelFilter => !!filter)
const facilityFilters = facilityFilterList.filter((filter) =>
HOTEL_FACILITIES_FILTER_TYPE_NAMES.includes(filter.filterType)
)
const surroundingsFilters = facilityFilterList.filter((filter) =>
HOTEL_SURROUNDINGS_FILTER_TYPE_NAMES.includes(filter.filterType)
)
return {
facilityFilters: sortFilters(facilityFilters),
surroundingsFilters: sortFilters(surroundingsFilters),
countryFilters: sortFilters(countryFilters),
}
}
function getLocalizedCountryByCountryCode(
countryCode: string,
lang: Lang
): string | null {
const country = new Intl.DisplayNames([lang], { type: "region" })
const localizedCountry = country.of(countryCode)
if (!localizedCountry) {
console.error(`Could not map ${countryCode} to localized country.`)
return null
}
return localizedCountry
}

View File

@@ -1,17 +1,17 @@
import { SortOption } from "../enums/destinationFilterAndSort"
import { HotelSortOption } from "../types/hotel"
import type { DestinationCityListItem } from "../types/destinationCityPage"
const CITY_SORTING_STRATEGIES: Partial<
Record<
SortOption,
HotelSortOption,
(a: DestinationCityListItem, b: DestinationCityListItem) => number
>
> = {
[SortOption.Name]: function (a, b) {
[HotelSortOption.Name]: function (a, b) {
return a.cityName.localeCompare(b.cityName)
},
[SortOption.Recommended]: function (a, b) {
[HotelSortOption.Recommended]: function (a, b) {
if (a.sort_order === null && b.sort_order === null) {
return a.cityName.localeCompare(b.cityName)
}
@@ -27,7 +27,7 @@ const CITY_SORTING_STRATEGIES: Partial<
export function getSortedCities(
cities: DestinationCityListItem[],
sortOption: SortOption
sortOption: HotelSortOption
) {
const sortFn = CITY_SORTING_STRATEGIES[sortOption]
return sortFn ? cities.sort(sortFn) : cities