Merged in feat/SW-2033-implement-new-room-feature-request (pull request #1665)

feat(SW-2033): Added new route for fetching room features, and merged the data with existing availability data

* feat(SW-2033): Added new route for fetching room features, and merged the data with existing availability data

* fix: issue with total price not including room features

* fix: add return null

* fix

* fix

* fixes from PR feedback


Approved-by: Arvid Norlin
This commit is contained in:
Tobias Johansson
2025-03-31 11:20:49 +00:00
parent 81f579bbfe
commit 7434f30c20
9 changed files with 232 additions and 12 deletions

View File

@@ -1,6 +1,12 @@
import { useSearchParams } from "next/navigation"
import { useEffect, useMemo, useState } from "react"
import { trpc } from "@/lib/trpc/client"
import { convertSearchParamsToObj, searchParamsToRecord } from "@/utils/url"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { SelectRateSearchParams } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Lang } from "@/constants/languages"
import type { ChildrenInRoom } from "@/utils/hotelSearchDetails"
@@ -14,16 +20,76 @@ export function useRoomsAvailability(
bookingCode?: string,
redemption?: boolean
) {
return trpc.hotel.availability.roomsCombinedAvailability.useQuery({
adultsCount,
bookingCode,
childArray,
hotelId,
lang,
redemption,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
})
const searchParams = useSearchParams()
const searchParamsObj = convertSearchParamsToObj<SelectRateSearchParams>(
searchParamsToRecord(searchParams)
)
const hasPackagesParam = searchParamsObj.rooms.some((room) => room.packages)
const [hasRoomFeatures, setHasRoomFeatures] = useState(hasPackagesParam)
useEffect(() => {
setHasRoomFeatures(hasPackagesParam)
}, [hasPackagesParam, setHasRoomFeatures])
const { data: roomFeatures, isPending: isRoomFeaturesPending } =
trpc.hotel.availability.roomFeatures.useQuery(
{
hotelId,
startDate: fromDateString,
endDate: toDateString,
adultsCount,
childArray,
},
{
enabled: hasRoomFeatures,
}
)
const { data: roomsAvailability, isPending: isRoomsAvailabiltyPending } =
trpc.hotel.availability.roomsCombinedAvailability.useQuery({
adultsCount,
bookingCode,
childArray,
hotelId,
lang,
redemption,
roomStayEndDate: toDateString,
roomStayStartDate: fromDateString,
})
const combinedData = useMemo(() => {
if (!roomsAvailability) {
return undefined
}
if (!roomFeatures) {
return roomsAvailability
}
return roomsAvailability.map((room, idx) => {
if ("error" in room) {
return room
}
return {
...room,
roomConfigurations: room.roomConfigurations.map((config) => ({
...config,
features:
roomFeatures?.[idx]?.find(
(r) => r.roomTypeCode === config.roomTypeCode
)?.features ?? [],
})),
}
})
}, [roomFeatures, roomsAvailability])
return {
data: combinedData,
isPending: hasRoomFeatures
? isRoomsAvailabiltyPending || isRoomFeaturesPending
: isRoomsAvailabiltyPending,
}
}
export function useHotelPackages(

View File

@@ -25,6 +25,7 @@ export namespace endpoints {
Reward = "Reward",
Stays = "Stays",
Transaction = "Transaction",
RoomFeature = "RoomFeature",
}
}
@@ -44,6 +45,9 @@ export namespace endpoints {
export function hotel(hotelId: string) {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel/${hotelId}`
}
export function roomFeatures(hotelId: string) {
return `${base.path.availability}/${version}/${base.enitity.RoomFeature}/features/hotel/${hotelId}`
}
}
/**

View File

@@ -2,6 +2,8 @@ import { z } from "zod"
import { Lang } from "@/constants/languages"
import { nullableArrayObjectValidator } from "@/utils/zod/arrayValidator"
import { ChildBedMapEnum } from "@/types/components/bookingWidget/enums"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { Country } from "@/types/enums/country"
@@ -159,3 +161,21 @@ export const getHotelsByCityIdentifierInput = z.object({
export const getLocationsInput = z.object({
lang: z.nativeEnum(Lang),
})
export const roomFeaturesInputSchema = z.object({
hotelId: z.string(),
startDate: z.string(),
endDate: z.string(),
adultsCount: z.array(z.number()),
childArray: z
.array(
nullableArrayObjectValidator(
z.object({
age: z.number(),
bed: z.nativeEnum(ChildBedMapEnum),
})
)
)
.nullable(),
roomFeatureCode: z.array(z.nativeEnum(RoomPackageCodeEnum)).optional(),
})

View File

@@ -84,4 +84,9 @@ export const metrics = {
fail: meter.createCounter("trpc.hotel.availability.room-fail"),
success: meter.createCounter("trpc.hotel.availability.room-success"),
},
roomFeatures: {
counter: meter.createCounter("trpc.availability.roomfeature"),
fail: meter.createCounter("trpc.availability.roomfeature-fail"),
success: meter.createCounter("trpc.availability.roomfeature-success"),
},
}

View File

@@ -25,6 +25,7 @@ import { roomConfigurationSchema } from "./schemas/roomAvailability/configuratio
import { rateDefinitionSchema } from "./schemas/roomAvailability/rateDefinition"
import { AvailabilityEnum } from "@/types/components/hotelReservation/selectHotel/selectHotel"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { RateTypeEnum } from "@/types/enums/rateType"
import type {
AdditionalData,
@@ -616,3 +617,32 @@ export const getNearbyHotelIdsSchema = z
),
})
.transform((data) => data.data.map((hotel) => hotel.id))
export const roomFeaturesSchema = z
.object({
data: z.object({
attributes: z.object({
hotelId: z.number(),
roomFeatures: z
.array(
z.object({
roomTypeCode: z.string(),
features: z.array(
z.object({
inventory: z.number(),
code: z.enum([
RoomPackageCodeEnum.PET_ROOM,
RoomPackageCodeEnum.ALLERGY_ROOM,
RoomPackageCodeEnum.ACCESSIBILITY_ROOM,
]),
})
),
})
)
.default([]),
}),
}),
})
.transform((data) => {
return data.data.attributes.roomFeatures
})

View File

@@ -38,6 +38,7 @@ import {
hotelsAvailabilityInputSchema,
nearbyHotelIdsInput,
ratesInputSchema,
roomFeaturesInputSchema,
roomPackagesInputSchema,
roomsCombinedAvailabilityInputSchema,
selectedRoomAvailabilityInputSchema,
@@ -51,6 +52,7 @@ import {
hotelSchema,
packagesSchema,
ratesSchema,
roomFeaturesSchema,
roomsAvailabilitySchema,
} from "./output"
import tempRatesData from "./tempRatesData.json"
@@ -917,6 +919,77 @@ export const hotelQueryRouter = router({
.concat(unavailableHotels.availability),
}
}),
roomFeatures: serviceProcedure
.input(roomFeaturesInputSchema)
.query(async ({ input, ctx }) => {
const { hotelId, startDate, endDate, adultsCount, childArray } = input
const responses = await Promise.allSettled(
adultsCount.map(async (adultCount, index) => {
const kids = childArray?.[index]
const params = {
hotelId,
roomStayStartDate: startDate,
roomStayEndDate: endDate,
adults: adultCount,
...(kids?.length && { children: generateChildrenString(kids) }),
}
metrics.roomFeatures.counter.add(1, params)
const apiResponse = await api.get(
api.endpoints.v1.Availability.roomFeatures(hotelId),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
const text = apiResponse.text()
console.error(
"api.availability.roomfeature error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
metrics.roomFeatures.fail.add(1, params)
return null
}
const data = await apiResponse.json()
const validatedRoomFeaturesData = roomFeaturesSchema.safeParse(data)
if (!validatedRoomFeaturesData.success) {
console.error(
"api.availability.roomfeature error",
JSON.stringify({
query: { hotelId, params },
error: validatedRoomFeaturesData.error,
})
)
return null
}
metrics.roomFeatures.success.add(1, params)
return validatedRoomFeaturesData.data
})
)
return responses.map((features) => {
if (features.status === "fulfilled") {
return features.value
}
return null
})
}),
}),
rates: router({
get: publicProcedure.input(ratesInputSchema).query(async () => {

View File

@@ -1,3 +1,4 @@
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import type { AvailabilityError } from "@/types/stores/rates"
import type {
Product,
@@ -78,3 +79,11 @@ export function findSelectedRate(
return findProductInRoom(rateCode, room)
})
}
export function isRoomPackageCode(
code: string | null
): code is RoomPackageCodeEnum {
return Object.values(RoomPackageCodeEnum).includes(
code as RoomPackageCodeEnum
)
}

View File

@@ -5,7 +5,11 @@ import { create, useStore } from "zustand"
import { RatesContext } from "@/contexts/Rates"
import { findProductInRoom, findSelectedRate } from "./helpers"
import {
findProductInRoom,
findSelectedRate,
isRoomPackageCode,
} from "./helpers"
import { RoomPackageCodeEnum } from "@/types/components/hotelReservation/selectRate/roomFilter"
import { BookingCodeFilterEnum } from "@/types/enums/bookingCodeFilter"
@@ -83,6 +87,7 @@ export function createRatesStore({
rate: product.rate,
roomType: selectedRoom.roomType,
roomTypeCode: selectedRoom.roomTypeCode,
package: room.packages?.[0],
}
}
}
@@ -125,7 +130,11 @@ export function createRatesStore({
product = findProductInRoom(room.rateCode, selectedRate)
}
const selectedPackage = room.packages?.[0]
// Since features are fetched async based on query string, we need to read from query string to apply correct filtering
const packagesParam = searchParams.get(`room[${idx}].packages`)
const selectedPackage = isRoomPackageCode(packagesParam)
? packagesParam
: undefined
let rooms: RoomConfiguration[] = roomConfiguration
if (selectedPackage) {

View File

@@ -47,6 +47,10 @@ export function getSearchParamFromKey(key: string): string {
return key
}
export function searchParamsToRecord(searchParams: URLSearchParams) {
return Object.fromEntries(searchParams.entries())
}
export function convertSearchParamsToObj<T extends PartialRoom>(
searchParams: Record<string, string>
) {