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:
@@ -8,8 +8,8 @@ export const HotelFilter = gql`
|
||||
facility_id
|
||||
category
|
||||
slug
|
||||
sort_order
|
||||
}
|
||||
${System}
|
||||
`
|
||||
|
||||
export const HotelFilterRef = gql`
|
||||
|
||||
14
packages/trpc/lib/graphql/Query/HotelFilters.graphql.ts
Normal file
14
packages/trpc/lib/graphql/Query/HotelFilters.graphql.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { gql } from "graphql-tag"
|
||||
|
||||
import { HotelFilter } from "../Fragments/HotelFilter.graphql"
|
||||
|
||||
export const GetHotelFilters = gql`
|
||||
query GetHotelFilters($locale: String!) {
|
||||
all_hotel_filter(locale: $locale) {
|
||||
items {
|
||||
...HotelFilter
|
||||
}
|
||||
}
|
||||
}
|
||||
${HotelFilter}
|
||||
`
|
||||
@@ -1,16 +1,13 @@
|
||||
import { ApiCountry } from "../../../types/country"
|
||||
import { HotelSortOption } from "../../../types/hotel"
|
||||
import {
|
||||
getFiltersFromHotels,
|
||||
mergeHotelFiltersAndSeoFilters,
|
||||
} from "../../../utils/getFiltersFromHotels"
|
||||
import { getSortedCities } from "../../../utils/getSortedCities"
|
||||
import { mergeHotelFiltersAndSeoFilters } from "../../../utils/mergeHotelFiltersAndSeoFilters"
|
||||
import { getHotelFilters } from "../../hotels/filters/utils"
|
||||
import {
|
||||
getCityByCityIdentifier,
|
||||
getHotelIdsByCityIdentifier,
|
||||
} from "../../hotels/services/getCityByCityIdentifier"
|
||||
import { getHotelIdsByCountry } from "../../hotels/services/getHotelIdsByCountry"
|
||||
import { getHotelsByHotelIds } from "../../hotels/services/getHotelsByHotelIds"
|
||||
import { getCityPages } from "../destinationCountryPage/utils"
|
||||
import { transformDestinationFiltersResponse } from "../schemas/destinationFilters"
|
||||
|
||||
@@ -62,11 +59,9 @@ export async function getCityData(
|
||||
serviceToken
|
||||
)
|
||||
|
||||
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
||||
|
||||
let filterType
|
||||
if (filter) {
|
||||
const hotelFilters = getFiltersFromHotels(hotels, lang)
|
||||
const hotelFilters = await getHotelFilters(lang)
|
||||
const allFilters = mergeHotelFiltersAndSeoFilters(
|
||||
hotelFilters,
|
||||
seoFilters
|
||||
@@ -104,6 +99,7 @@ export async function getCountryData(
|
||||
lang: Lang
|
||||
) {
|
||||
const country = data.destination_settings?.country
|
||||
const seoFilters = transformDestinationFiltersResponse(data.seo_filters)
|
||||
const filter = input.filterFromUrl
|
||||
|
||||
if (country) {
|
||||
@@ -117,10 +113,13 @@ export async function getCountryData(
|
||||
serviceToken,
|
||||
})
|
||||
|
||||
const hotels = await getHotelsByHotelIds({ hotelIds, lang, serviceToken })
|
||||
|
||||
if (filter) {
|
||||
const allFilters = getFiltersFromHotels(hotels, lang)
|
||||
const hotelFilters = await getHotelFilters(lang)
|
||||
const allFilters = mergeHotelFiltersAndSeoFilters(
|
||||
hotelFilters,
|
||||
seoFilters
|
||||
)
|
||||
|
||||
const facilityFilter = allFilters.facilityFilters.find(
|
||||
(f) => f.slug === filter
|
||||
)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
|
||||
import { isDefined } from "@scandic-hotels/common/utils/isDefined"
|
||||
|
||||
import { DestinationFilterBlocksEnum } from "../../../types/destinationsData"
|
||||
import { discriminatedUnionArray } from "../../../utils/discriminatedUnion"
|
||||
import { hotelFilterSchema } from "../../hotels/filters/output"
|
||||
import { accordionSchema } from "./blocks/accordion"
|
||||
import { contentSchema } from "./blocks/content"
|
||||
import { systemSchema } from "./system"
|
||||
@@ -37,12 +37,7 @@ export const destinationFilterSchema = z.object({
|
||||
filterConnection: z.object({
|
||||
edges: z.array(
|
||||
z.object({
|
||||
node: z.object({
|
||||
title: z.string(),
|
||||
facility_id: z.nativeEnum(FacilityEnum).catch(FacilityEnum.UNKNOWN),
|
||||
category: z.string(),
|
||||
slug: z.string(),
|
||||
}),
|
||||
node: hotelFilterSchema,
|
||||
})
|
||||
),
|
||||
}),
|
||||
@@ -84,13 +79,7 @@ export function transformDestinationFiltersResponse(
|
||||
heading,
|
||||
preamble,
|
||||
blocks,
|
||||
filter: {
|
||||
id: filter.facility_id,
|
||||
name: filter.title,
|
||||
filterType: filter.category,
|
||||
slug: filter.slug,
|
||||
sortOrder: 0,
|
||||
},
|
||||
filter,
|
||||
}
|
||||
})
|
||||
.filter(isDefined)
|
||||
|
||||
6
packages/trpc/lib/routers/hotels/filters/get.ts
Normal file
6
packages/trpc/lib/routers/hotels/filters/get.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { contentstackBaseProcedure } from "../../../procedures"
|
||||
import { getHotelFilters } from "./utils"
|
||||
|
||||
export const get = contentstackBaseProcedure.query(async ({ ctx }) => {
|
||||
return getHotelFilters(ctx.lang)
|
||||
})
|
||||
6
packages/trpc/lib/routers/hotels/filters/index.ts
Normal file
6
packages/trpc/lib/routers/hotels/filters/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { router } from "../../.."
|
||||
import { get } from "./get"
|
||||
|
||||
export const filtersRouter = router({
|
||||
get,
|
||||
})
|
||||
61
packages/trpc/lib/routers/hotels/filters/output.ts
Normal file
61
packages/trpc/lib/routers/hotels/filters/output.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { z } from "zod"
|
||||
|
||||
import { Lang } from "@scandic-hotels/common/constants/language"
|
||||
|
||||
import { getCountryFilters } from "./utils"
|
||||
|
||||
export const FILTER_TYPES = ["surroundings", "facility", "country"] as const
|
||||
export type FilterType = (typeof FILTER_TYPES)[number]
|
||||
|
||||
export const hotelFilterSchema = z
|
||||
.object({
|
||||
category: z.enum(FILTER_TYPES),
|
||||
facility_id: z.number().transform((id) => id.toString()),
|
||||
slug: z.string(),
|
||||
title: z.string(),
|
||||
sort_order: z.number().nullish(),
|
||||
})
|
||||
.transform(({ facility_id, title, category, slug, sort_order }) => ({
|
||||
id: facility_id,
|
||||
name: title,
|
||||
filterType: category,
|
||||
slug,
|
||||
sortOrder: sort_order ?? 0,
|
||||
hotelCount: 0,
|
||||
cityCount: 0,
|
||||
}))
|
||||
|
||||
export const hotelFiltersSchema = z
|
||||
.object({
|
||||
all_hotel_filter: z.object({
|
||||
items: z.array(hotelFilterSchema),
|
||||
}),
|
||||
lang: z.nativeEnum(Lang),
|
||||
})
|
||||
.transform((data) => {
|
||||
const filters = data.all_hotel_filter.items
|
||||
|
||||
if (!filters?.length) {
|
||||
return {
|
||||
facilityFilters: [],
|
||||
surroundingsFilters: [],
|
||||
countryFilters: [],
|
||||
}
|
||||
}
|
||||
|
||||
const facilityFilters = filters.filter(
|
||||
(filter) => filter.filterType === "facility"
|
||||
)
|
||||
const surroundingsFilters = filters.filter(
|
||||
(filter) => filter.filterType === "surroundings"
|
||||
)
|
||||
|
||||
return {
|
||||
facilityFilters,
|
||||
surroundingsFilters,
|
||||
countryFilters: getCountryFilters(data.lang),
|
||||
}
|
||||
})
|
||||
|
||||
export type HotelFilters = z.output<typeof hotelFiltersSchema>
|
||||
export type HotelFilter = HotelFilters[keyof HotelFilters][number]
|
||||
98
packages/trpc/lib/routers/hotels/filters/utils.ts
Normal file
98
packages/trpc/lib/routers/hotels/filters/utils.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { CountryCode } from "@scandic-hotels/common/constants/country"
|
||||
import { getCacheClient } from "@scandic-hotels/common/dataCache"
|
||||
import { createCounter } from "@scandic-hotels/common/telemetry"
|
||||
|
||||
import { GetHotelFilters } from "../../../graphql/Query/HotelFilters.graphql"
|
||||
import { request } from "../../../graphql/request"
|
||||
import { hotelFiltersSchema } from "./output"
|
||||
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
|
||||
import type { FilterType, HotelFilter } from "./output"
|
||||
|
||||
export async function getHotelFilters(lang: Lang) {
|
||||
const cacheClient = await getCacheClient()
|
||||
const cacheKey = `${lang}:getHotelFilters`
|
||||
|
||||
return await cacheClient.cacheOrGet(
|
||||
cacheKey,
|
||||
async () => {
|
||||
const getHotelFiltersCounter = createCounter(
|
||||
"trpc.contentstack.hotelFilters.getAll"
|
||||
)
|
||||
const metricsGetHotelFilters = getHotelFiltersCounter.init({ lang })
|
||||
|
||||
metricsGetHotelFilters.start()
|
||||
|
||||
const response = await request<unknown>(
|
||||
GetHotelFilters,
|
||||
{ locale: lang },
|
||||
{ key: `${lang}:hotel_filters`, ttl: "1d" }
|
||||
)
|
||||
|
||||
if (!response.data) {
|
||||
metricsGetHotelFilters.noDataError()
|
||||
return {
|
||||
facilityFilters: [],
|
||||
surroundingsFilters: [],
|
||||
countryFilters: [],
|
||||
}
|
||||
}
|
||||
|
||||
const validatedResponse = hotelFiltersSchema.safeParse({
|
||||
...response.data,
|
||||
lang,
|
||||
})
|
||||
if (!validatedResponse.success) {
|
||||
metricsGetHotelFilters.validationError(validatedResponse.error)
|
||||
return {
|
||||
facilityFilters: [],
|
||||
surroundingsFilters: [],
|
||||
countryFilters: [],
|
||||
}
|
||||
}
|
||||
|
||||
metricsGetHotelFilters.success()
|
||||
|
||||
const { facilityFilters, surroundingsFilters, countryFilters } =
|
||||
validatedResponse.data
|
||||
return {
|
||||
facilityFilters: sortFilters(facilityFilters),
|
||||
surroundingsFilters: sortFilters(surroundingsFilters),
|
||||
countryFilters: sortFilters(countryFilters),
|
||||
}
|
||||
},
|
||||
"1d"
|
||||
)
|
||||
}
|
||||
|
||||
export function getCountryFilters(lang: Lang) {
|
||||
const countryCodes = Object.values(CountryCode)
|
||||
const localizedCountries = new Intl.DisplayNames([lang], { type: "region" })
|
||||
return [...countryCodes]
|
||||
.map((countryCode) => {
|
||||
const localizedCountryName = localizedCountries.of(countryCode)
|
||||
|
||||
return localizedCountryName
|
||||
? {
|
||||
id: countryCode,
|
||||
name: localizedCountryName,
|
||||
slug: countryCode.toLowerCase(),
|
||||
filterType: "country" as FilterType,
|
||||
sortOrder: 0,
|
||||
hotelCount: 0,
|
||||
cityCount: 0,
|
||||
}
|
||||
: null
|
||||
})
|
||||
.filter((filter) => !!filter)
|
||||
}
|
||||
|
||||
// First sort by sortOrder descending. The higher the sortOrder, the earlier it appears.
|
||||
// If sortOrder is the same, sort by name as secondary criterion
|
||||
export function sortFilters(filters: HotelFilter[]) {
|
||||
return [...filters].sort((a, b) => {
|
||||
const orderDiff = b.sortOrder - a.sortOrder
|
||||
return orderDiff === 0 ? a.name.localeCompare(b.name) : orderDiff
|
||||
})
|
||||
}
|
||||
@@ -47,6 +47,7 @@ import { getHotelsByHotelIds } from "./services/getHotelsByHotelIds"
|
||||
import { getLocationsByCountries } from "./services/getLocationsByCountries"
|
||||
import { getPackages } from "./services/getPackages"
|
||||
import { availability } from "./availability"
|
||||
import { filtersRouter } from "./filters"
|
||||
import { locationsRouter } from "./locations"
|
||||
|
||||
import type { HotelListingHotelData } from "../../types/hotel"
|
||||
@@ -331,6 +332,7 @@ export const hotelQueryRouter = router({
|
||||
env.CACHE_TIME_HOTELS
|
||||
)
|
||||
}),
|
||||
filters: filtersRouter,
|
||||
locations: locationsRouter,
|
||||
map: router({
|
||||
city: serviceProcedure
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
|
||||
import type { z } from "zod"
|
||||
|
||||
import type {
|
||||
@@ -87,20 +86,6 @@ export type RoomType = Pick<Room, "roomTypes" | "name">
|
||||
|
||||
export type RewardNight = z.output<typeof rewardNightSchema>
|
||||
|
||||
export interface HotelFilter {
|
||||
id: FacilityEnum
|
||||
name: string
|
||||
slug: string
|
||||
filterType: string
|
||||
sortOrder: number
|
||||
}
|
||||
|
||||
export interface CategorizedHotelFilters {
|
||||
facilityFilters: HotelFilter[]
|
||||
surroundingsFilters: HotelFilter[]
|
||||
countryFilters: HotelFilter[]
|
||||
}
|
||||
|
||||
export enum HotelSortOption {
|
||||
Recommended = "recommended",
|
||||
Distance = "distance",
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
import { logger } from "@scandic-hotels/common/logger"
|
||||
|
||||
import type { FacilityEnum } from "@scandic-hotels/common/constants/facilities"
|
||||
import type { Lang } from "@scandic-hotels/common/constants/language"
|
||||
|
||||
import type { DestinationFilters } from "../types/destinationsData"
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
// Merges hotel and SEO filters, removing duplicates (by id).
|
||||
// In case of duplicates, the SEO filter takes precedence.
|
||||
function mergeAndDeduplicate(
|
||||
hotelFilters: HotelFilter[],
|
||||
seoFilters:
|
||||
| DestinationFilters["facilityFilters"]
|
||||
| DestinationFilters["surroundingsFilters"]
|
||||
): HotelFilter[] {
|
||||
const map = new Map<FacilityEnum, HotelFilter>()
|
||||
hotelFilters.forEach((filter) => map.set(filter.id, filter))
|
||||
seoFilters.forEach(({ filter }) => map.set(filter.id, filter))
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
export function mergeHotelFiltersAndSeoFilters(
|
||||
hotelFilters: CategorizedHotelFilters,
|
||||
seoFilters: DestinationFilters
|
||||
): CategorizedHotelFilters {
|
||||
if (!seoFilters) {
|
||||
return hotelFilters
|
||||
}
|
||||
|
||||
return {
|
||||
...hotelFilters,
|
||||
facilityFilters: mergeAndDeduplicate(
|
||||
hotelFilters.facilityFilters,
|
||||
seoFilters.facilityFilters
|
||||
),
|
||||
surroundingsFilters: mergeAndDeduplicate(
|
||||
hotelFilters.surroundingsFilters,
|
||||
seoFilters.surroundingsFilters
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
? {
|
||||
id: filter.id,
|
||||
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) {
|
||||
logger.error(`Could not map ${countryCode} to localized country.`)
|
||||
return null
|
||||
}
|
||||
|
||||
return localizedCountry
|
||||
}
|
||||
43
packages/trpc/lib/utils/mergeHotelFiltersAndSeoFilters.ts
Normal file
43
packages/trpc/lib/utils/mergeHotelFiltersAndSeoFilters.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// Merges hotel and SEO filters, removing duplicates (by id).
|
||||
|
||||
import type {
|
||||
HotelFilter,
|
||||
HotelFilters,
|
||||
} from "../routers/hotels/filters/output"
|
||||
import type {
|
||||
DestinationFilter,
|
||||
DestinationFilters,
|
||||
} from "../types/destinationsData"
|
||||
|
||||
// In case of duplicates, the SEO filter takes precedence.
|
||||
function mergeAndDeduplicate(
|
||||
hotelFilters: HotelFilter[],
|
||||
seoFilters: DestinationFilter[]
|
||||
): HotelFilters["facilityFilters"] | HotelFilters["surroundingsFilters"] {
|
||||
const map = new Map<string, HotelFilter>()
|
||||
hotelFilters.forEach((filter) => map.set(filter.id, filter))
|
||||
seoFilters.forEach(({ filter }) => map.set(filter.id, filter))
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
export function mergeHotelFiltersAndSeoFilters(
|
||||
hotelFilters: HotelFilters,
|
||||
seoFilters: DestinationFilters
|
||||
): HotelFilters {
|
||||
if (!seoFilters) {
|
||||
return hotelFilters
|
||||
}
|
||||
|
||||
return {
|
||||
...hotelFilters,
|
||||
facilityFilters: mergeAndDeduplicate(
|
||||
hotelFilters.facilityFilters,
|
||||
seoFilters.facilityFilters
|
||||
),
|
||||
surroundingsFilters: mergeAndDeduplicate(
|
||||
hotelFilters.surroundingsFilters,
|
||||
seoFilters.surroundingsFilters
|
||||
),
|
||||
countryFilters: hotelFilters.countryFilters,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user