feat(SW-664): Hotel listing component and queries for content pages

This commit is contained in:
Erik Tiekstra
2024-12-11 14:46:38 +01:00
parent 118f1afafa
commit 3939bf7cdc
32 changed files with 989 additions and 140 deletions

View File

@@ -1,6 +1,7 @@
import { z } from "zod"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { Country } from "@/types/enums/country"
export const getHotelsAvailabilityInputSchema = z.object({
cityId: z.string(),
@@ -53,6 +54,18 @@ export const getHotelDataInputSchema = z.object({
export type HotelDataInput = z.input<typeof getHotelDataInputSchema>
export const getHotelsInput = z.object({
locationFilter: z
.object({
city: z.string().nullable(),
country: z.nativeEnum(Country).nullable(),
excluded: z.array(z.string()),
})
.nullable(),
hotelsToInclude: z.array(z.string()),
})
export interface GetHotelsInput extends z.infer<typeof getHotelsInput> {}
export const getBreakfastPackageInputSchema = z.object({
adults: z.number().min(1, { message: "at least one adult is required" }),
fromDate: z

View File

@@ -870,3 +870,14 @@ export const getRoomPackagesSchema = z
.optional(),
})
.transform((data) => data.data?.attributes?.packages ?? [])
export const getHotelIdsByCityIdSchema = z
.object({
data: z.array(
z.object({
// We only care about the hotel id
id: z.string(),
})
),
})
.transform((data) => data.data.map((hotel) => hotel.id))

View File

@@ -1,9 +1,8 @@
import { metrics } from "@opentelemetry/api"
import * as api from "@/lib/api"
import { dt } from "@/lib/dt"
import { badRequestError } from "@/server/errors/trpc"
import {
contentStackBaseWithServiceProcedure,
publicProcedure,
router,
safeProtectedServiceProcedure,
@@ -13,12 +12,14 @@ import { toApiLang } from "@/server/utils"
import { cache } from "@/utils/cache"
import { getHotelPageUrl } from "../contentstack/hotelPage/utils"
import { getVerifiedUser, parsedUser } from "../user/query"
import {
getBreakfastPackageInputSchema,
getCityCoordinatesInputSchema,
getHotelDataInputSchema,
getHotelsAvailabilityInputSchema,
getHotelsInput,
getRatesInputSchema,
getRoomPackagesInputSchema,
getRoomsAvailabilityInputSchema,
@@ -33,10 +34,35 @@ import {
getRoomPackagesSchema,
getRoomsAvailabilitySchema,
} from "./output"
import {
breakfastPackagesCounter,
breakfastPackagesFailCounter,
breakfastPackagesSuccessCounter,
getHotelCounter,
getHotelFailCounter,
getHotelsCounter,
getHotelsFailCounter,
getHotelsSuccessCounter,
getHotelSuccessCounter,
getPackagesCounter,
getPackagesFailCounter,
getPackagesSuccessCounter,
hotelsAvailabilityCounter,
hotelsAvailabilityFailCounter,
hotelsAvailabilitySuccessCounter,
roomsAvailabilityCounter,
roomsAvailabilityFailCounter,
roomsAvailabilitySuccessCounter,
selectedRoomAvailabilityCounter,
selectedRoomAvailabilityFailCounter,
selectedRoomAvailabilitySuccessCounter,
} from "./telemetry"
import tempRatesData from "./tempRatesData.json"
import {
getCitiesByCountry,
getCountries,
getHotelIdsByCityId,
getHotelIdsByCountry,
getLocations,
TWENTYFOUR_HOURS,
} from "./utils"
@@ -45,57 +71,9 @@ import type { BedTypeSelection } from "@/types/components/hotelReservation/enter
import { BreakfastPackageEnum } from "@/types/enums/breakfast"
import { HotelTypeEnum } from "@/types/enums/hotelType"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
const meter = metrics.getMeter("trpc.hotels")
const getHotelCounter = meter.createCounter("trpc.hotel.get")
const getHotelSuccessCounter = meter.createCounter("trpc.hotel.get-success")
const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
const getPackagesSuccessCounter = meter.createCounter(
"trpc.hotel.packages.get-success"
)
const getPackagesFailCounter = meter.createCounter(
"trpc.hotel.packages.get-fail"
)
const hotelsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.hotels"
)
const hotelsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.hotels-success"
)
const hotelsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-fail"
)
const roomsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.rooms"
)
const roomsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.rooms-success"
)
const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail"
)
const selectedRoomAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.room"
)
const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.room-success"
)
const selectedRoomAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.room-fail"
)
const breakfastPackagesCounter = meter.createCounter("trpc.package.breakfast")
const breakfastPackagesSuccessCounter = meter.createCounter(
"trpc.package.breakfast-success"
)
const breakfastPackagesFailCounter = meter.createCounter(
"trpc.package.breakfast-fail"
)
import type { Hotel } from "@/types/hotel"
import type { HotelPageUrl } from "@/types/trpc/routers/contentstack/hotelPage"
import type { CityLocation } from "@/types/trpc/routers/hotel/locations"
export const getHotelData = cache(
async (input: HotelDataInput, serviceToken: string) => {
@@ -695,6 +673,199 @@ export const hotelQueryRouter = router({
return getHotelData(input, ctx.serviceToken)
}),
}),
hotels: router({
get: contentStackBaseWithServiceProcedure
.input(getHotelsInput)
.query(async function ({ ctx, input }) {
const { locationFilter, hotelsToInclude } = input
const language = ctx.lang
const options: RequestOptionsWithOutBody = {
cache: "force-cache",
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
next: {
revalidate: TWENTYFOUR_HOURS,
},
}
let hotelsToFetch: string[] = []
getHotelsCounter.add(1, {
input: JSON.stringify(input),
language,
})
console.info(
"api.hotel.hotels start",
JSON.stringify({
query: {
...input,
language,
},
})
)
if (hotelsToInclude.length) {
hotelsToFetch = hotelsToInclude
} else if (locationFilter?.city) {
const locationsParams = new URLSearchParams({
language: toApiLang(ctx.lang),
})
const locations = await getLocations(
ctx.lang,
options,
locationsParams,
null
)
if (!locations || "error" in locations) {
return []
}
const cityId = locations
.filter((loc): loc is CityLocation => loc.type === "cities")
.find((loc) => loc.cityIdentifier === locationFilter.city)?.id
if (!cityId) {
getHotelsFailCounter.add(1, {
input: JSON.stringify(input),
language,
error_type: "not_found",
error: `CityId not found for cityIdentifier: ${locationFilter.city}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: { ...input, language },
error: `CityId not found for cityIdentifier: ${locationFilter.city}`,
})
)
return []
}
const hotelIdsParams = new URLSearchParams({
language: ctx.lang,
city: cityId,
onlyBasicInfo: "true",
})
const hotelIds = await getHotelIdsByCityId(
cityId,
options,
hotelIdsParams
)
if (!hotelIds?.length) {
getHotelsFailCounter.add(1, {
cityId,
language,
error_type: "not_found",
error: `No hotelIds found for cityId: ${cityId}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: { cityId, language },
error: `No hotelIds found for cityId: ${cityId}`,
})
)
return []
}
const filteredHotelIds = hotelIds.filter(
(id) => !locationFilter.excluded.includes(id)
)
hotelsToFetch = filteredHotelIds
} else if (locationFilter?.country) {
const hotelIdsParams = new URLSearchParams({
language: ctx.lang,
country: locationFilter.country,
onlyBasicInfo: "true",
})
const hotelIds = await getHotelIdsByCountry(
locationFilter.country,
options,
hotelIdsParams
)
if (!hotelIds?.length) {
getHotelsFailCounter.add(1, {
country: locationFilter.country,
language,
error_type: "not_found",
error: `No hotelIds found for country: ${locationFilter.country}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: { country: locationFilter.country, language },
error: `No hotelIds found for cityId: ${locationFilter.country}`,
})
)
return []
}
const filteredHotelIds = hotelIds.filter(
(id) => !locationFilter.excluded.includes(id)
)
hotelsToFetch = filteredHotelIds
}
if (!hotelsToFetch.length) {
getHotelsFailCounter.add(1, {
input: JSON.stringify(input),
language,
error_type: "not_found",
error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`,
})
console.error(
"api.hotel.hotels not found error",
JSON.stringify({
query: JSON.stringify(input),
error: `Couldn't find any hotels for given input: ${JSON.stringify(input)}`,
})
)
return []
}
const hotels = await Promise.all(
hotelsToFetch.map(async (hotelId) => {
const [hotelData, url] = await Promise.all([
getHotelData({ hotelId, language }, ctx.serviceToken),
getHotelPageUrl(language, hotelId),
])
return {
data: hotelData?.data.attributes,
url,
}
})
)
getHotelsSuccessCounter.add(1, {
input: JSON.stringify(input),
language,
})
console.info(
"api.hotels success",
JSON.stringify({
query: {
input: JSON.stringify(input),
language,
},
})
)
return hotels.filter(
(hotel): hotel is { data: Hotel; url: HotelPageUrl } => !!hotel.data
)
}),
}),
locations: router({
get: serviceProcedure.query(async function ({ ctx }) {
const searchParams = new URLSearchParams()

View File

@@ -0,0 +1,74 @@
import { metrics } from "@opentelemetry/api"
const meter = metrics.getMeter("trpc.hotels")
export const getHotelCounter = meter.createCounter("trpc.hotel.get")
export const getHotelSuccessCounter = meter.createCounter(
"trpc.hotel.get-success"
)
export const getHotelFailCounter = meter.createCounter("trpc.hotel.get-fail")
export const getPackagesCounter = meter.createCounter("trpc.hotel.packages.get")
export const getPackagesSuccessCounter = meter.createCounter(
"trpc.hotel.packages.get-success"
)
export const getPackagesFailCounter = meter.createCounter(
"trpc.hotel.packages.get-fail"
)
export const hotelsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.hotels"
)
export const hotelsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.hotels-success"
)
export const hotelsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-fail"
)
export const roomsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.rooms"
)
export const roomsAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.rooms-success"
)
export const roomsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.rooms-fail"
)
export const selectedRoomAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.room"
)
export const selectedRoomAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.room-success"
)
export const selectedRoomAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.room-fail"
)
export const breakfastPackagesCounter = meter.createCounter(
"trpc.package.breakfast"
)
export const breakfastPackagesSuccessCounter = meter.createCounter(
"trpc.package.breakfast-success"
)
export const breakfastPackagesFailCounter = meter.createCounter(
"trpc.package.breakfast-fail"
)
export const getHotelsCounter = meter.createCounter("trpc.hotel.hotels.get")
export const getHotelsSuccessCounter = meter.createCounter(
"trpc.hotel.hotels.get-success"
)
export const getHotelsFailCounter = meter.createCounter(
"trpc.hotel.hotels.get-fail"
)
export const getHotelIdsCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get"
)
export const getHotelIdsSuccessCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get-success"
)
export const getHotelIdsFailCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get-fail"
)

View File

@@ -10,11 +10,18 @@ import {
apiLocationsSchema,
type CitiesGroupedByCountry,
type Countries,
getHotelIdsByCityIdSchema,
} from "./output"
import {
getHotelIdsCounter,
getHotelIdsFailCounter,
getHotelIdsSuccessCounter,
} from "./telemetry"
import type { Country } from "@/types/enums/country"
import type { RequestOptionsWithOutBody } from "@/types/fetch"
import { PointOfInterestGroupEnum } from "@/types/hotel"
import { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages"
import type { Endpoint } from "@/lib/api/endpoints"
@@ -258,3 +265,145 @@ export async function getLocations(
{ revalidate: TWENTYFOUR_HOURS }
)(params, citiesByCountry)
}
export async function getHotelIdsByCityId(
cityId: string,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
getHotelIdsCounter.add(1, { cityId })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ query: { cityId } })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
)
if (!apiResponse.ok) {
const responseMessage = await apiResponse.text()
getHotelIdsFailCounter.add(1, {
cityId,
error_type: "http_error",
error: responseMessage,
})
console.error(
"api.hotel.hotel-ids fetch error",
JSON.stringify({
query: { cityId },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text: responseMessage,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
getHotelIdsFailCounter.add(1, {
cityId,
error_type: "validation_error",
error: JSON.stringify(validatedHotelIds.error),
})
console.error(
"api.hotel.hotel-ids validation error",
JSON.stringify({
query: { cityId },
error: validatedHotelIds.error,
})
)
return null
}
getHotelIdsSuccessCounter.add(1, { cityId })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({ query: { cityId } })
)
return validatedHotelIds.data
},
[`hotels`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
}
export async function getHotelIdsByCountry(
country: Country,
options: RequestOptionsWithOutBody,
params: URLSearchParams
) {
return unstable_cache(
async function (params: URLSearchParams) {
getHotelIdsCounter.add(1, { country })
console.info(
"api.hotel.hotel-ids start",
JSON.stringify({ query: { country } })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.hotels,
options,
params
)
if (!apiResponse.ok) {
const responseMessage = await apiResponse.text()
getHotelIdsFailCounter.add(1, {
country,
error_type: "http_error",
error: responseMessage,
})
console.error(
"api.hotel.hotel-ids fetch error",
JSON.stringify({
query: { country },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text: responseMessage,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validatedHotelIds = getHotelIdsByCityIdSchema.safeParse(apiJson)
if (!validatedHotelIds.success) {
getHotelIdsFailCounter.add(1, {
country,
error_type: "validation_error",
error: JSON.stringify(validatedHotelIds.error),
})
console.error(
"api.hotel.hotel-ids validation error",
JSON.stringify({
query: { country },
error: validatedHotelIds.error,
})
)
return null
}
getHotelIdsSuccessCounter.add(1, { country })
console.info(
"api.hotel.hotel-ids success",
JSON.stringify({ query: { country } })
)
return validatedHotelIds.data
},
[`hotels`, params.toString()],
{ revalidate: TWENTYFOUR_HOURS }
)(params)
}