fix(SW-2138): include hotel content descriptions in hotel data retrieval * fix(SW-2138): include hotel content descriptions in hotel data retrieval * fix(SW-2136 ): simplify hotel description handling * refactor(SW-2136): replace Body component with Typography for hotel descriptions in HotelListingItem Approved-by: Christian Andolf
756 lines
20 KiB
TypeScript
756 lines
20 KiB
TypeScript
import deepmerge from "deepmerge"
|
|
|
|
import { Lang } from "@/constants/languages"
|
|
import { env } from "@/env/server"
|
|
import * as api from "@/lib/api"
|
|
import { badRequestError } from "@/server/errors/trpc"
|
|
import { toApiLang } from "@/server/utils"
|
|
|
|
import { getCacheClient } from "@/services/dataCache"
|
|
|
|
import { getHotelPageUrls } from "../contentstack/hotelPage/utils"
|
|
import { metrics } from "./metrics"
|
|
import {
|
|
type Cities,
|
|
citiesByCountrySchema,
|
|
citiesSchema,
|
|
countriesSchema,
|
|
getHotelIdsSchema,
|
|
locationsSchema,
|
|
roomsAvailabilitySchema,
|
|
} from "./output"
|
|
import { getHotel } from "./query"
|
|
|
|
import type { z } from "zod"
|
|
|
|
import { PointOfInterestGroupEnum } from "@/types/enums/pointOfInterest"
|
|
import type { DestinationPagesHotelData } from "@/types/hotel"
|
|
import type {
|
|
CitiesGroupedByCountry,
|
|
CityLocation,
|
|
} from "@/types/trpc/routers/hotel/locations"
|
|
import type {
|
|
Product,
|
|
Products,
|
|
RateDefinition,
|
|
RedemptionsProduct,
|
|
} from "@/types/trpc/routers/hotel/roomAvailability"
|
|
import type { Endpoint } from "@/lib/api/endpoints"
|
|
import type { selectedRoomAvailabilityInputSchema } from "./input"
|
|
|
|
export function getPoiGroupByCategoryName(category: string | undefined) {
|
|
if (!category) return PointOfInterestGroupEnum.LOCATION
|
|
switch (category) {
|
|
case "Airport":
|
|
case "Bus terminal":
|
|
case "Transportations":
|
|
return PointOfInterestGroupEnum.PUBLIC_TRANSPORT
|
|
case "Amusement park":
|
|
case "Museum":
|
|
case "Sports":
|
|
case "Theatre":
|
|
case "Tourist":
|
|
case "Zoo":
|
|
return PointOfInterestGroupEnum.ATTRACTIONS
|
|
case "Nearby companies":
|
|
case "Fair":
|
|
return PointOfInterestGroupEnum.BUSINESS
|
|
case "Parking / Garage":
|
|
return PointOfInterestGroupEnum.PARKING
|
|
case "Shopping":
|
|
case "Restaurant":
|
|
return PointOfInterestGroupEnum.SHOPPING_DINING
|
|
case "Hospital":
|
|
default:
|
|
return PointOfInterestGroupEnum.LOCATION
|
|
}
|
|
}
|
|
|
|
export const locationsAffix = "locations"
|
|
|
|
export const TWENTYFOUR_HOURS = 60 * 60 * 24
|
|
export async function getCity({
|
|
cityUrl,
|
|
serviceToken,
|
|
}: {
|
|
cityUrl: string
|
|
serviceToken: string
|
|
}): Promise<Cities> {
|
|
const cacheClient = await getCacheClient()
|
|
return await cacheClient.cacheOrGet(
|
|
cityUrl,
|
|
async () => {
|
|
const url = new URL(cityUrl)
|
|
const cityResponse = await api.get(
|
|
url.pathname as Endpoint,
|
|
{ headers: { Authorization: `Bearer ${serviceToken}` } },
|
|
url.searchParams
|
|
)
|
|
|
|
if (!cityResponse.ok) {
|
|
return null
|
|
}
|
|
|
|
const cityJson = await cityResponse.json()
|
|
const city = citiesSchema.safeParse(cityJson)
|
|
if (!city.success) {
|
|
console.info(`Validation of city failed`)
|
|
console.info(`cityUrl: ${cityUrl}`)
|
|
console.error(city.error)
|
|
return null
|
|
}
|
|
|
|
return city.data
|
|
},
|
|
"1d"
|
|
)
|
|
}
|
|
|
|
export async function getCountries({
|
|
lang,
|
|
serviceToken,
|
|
}: {
|
|
lang: Lang
|
|
serviceToken: string
|
|
}) {
|
|
const cacheClient = await getCacheClient()
|
|
return await cacheClient.cacheOrGet(
|
|
`${lang}:${locationsAffix}:countries`,
|
|
async () => {
|
|
const params = new URLSearchParams({
|
|
language: toApiLang(lang),
|
|
})
|
|
|
|
const countryResponse = await api.get(
|
|
api.endpoints.v1.Hotel.countries,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
params
|
|
)
|
|
|
|
if (!countryResponse.ok) {
|
|
throw new Error("Unable to fetch countries")
|
|
}
|
|
|
|
const countriesJson = await countryResponse.json()
|
|
const countries = countriesSchema.safeParse(countriesJson)
|
|
if (!countries.success) {
|
|
console.info(`Validation for countries failed`)
|
|
console.error(countries.error)
|
|
return null
|
|
}
|
|
|
|
return countries.data
|
|
},
|
|
"1d"
|
|
)
|
|
}
|
|
|
|
export async function getCitiesByCountry({
|
|
countries,
|
|
lang,
|
|
onlyPublished = false,
|
|
affix = locationsAffix,
|
|
serviceToken,
|
|
}: {
|
|
countries: string[]
|
|
lang: Lang
|
|
onlyPublished?: boolean // false by default as it might be used in other places
|
|
affix?: string
|
|
serviceToken: string
|
|
}): Promise<CitiesGroupedByCountry> {
|
|
const cacheClient = await getCacheClient()
|
|
const allCitiesByCountries = await Promise.all(
|
|
countries.map(async (country) => {
|
|
return cacheClient.cacheOrGet(
|
|
`${lang}:${affix}:cities-by-country:${country}`,
|
|
async () => {
|
|
const params = new URLSearchParams({
|
|
language: toApiLang(lang),
|
|
})
|
|
const countryResponse = await api.get(
|
|
api.endpoints.v1.Hotel.Cities.country(country),
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
params
|
|
)
|
|
|
|
if (!countryResponse.ok) {
|
|
throw new Error(`Unable to fetch cities by country ${country}`)
|
|
}
|
|
|
|
const countryJson = await countryResponse.json()
|
|
const citiesByCountry = citiesByCountrySchema.safeParse(countryJson)
|
|
if (!citiesByCountry.success) {
|
|
console.error(`Unable to parse cities by country ${country}`)
|
|
console.error(citiesByCountry.error)
|
|
throw new Error(`Unable to parse cities by country ${country}`)
|
|
}
|
|
|
|
return { ...citiesByCountry.data, country }
|
|
},
|
|
"1d"
|
|
)
|
|
})
|
|
)
|
|
|
|
const filteredCitiesByCountries = allCitiesByCountries.map((country) => ({
|
|
...country,
|
|
data: onlyPublished
|
|
? country.data.filter((city) => city.isPublished)
|
|
: country.data,
|
|
}))
|
|
|
|
const groupedCitiesByCountry: CitiesGroupedByCountry =
|
|
filteredCitiesByCountries.reduce((acc, { country, data }) => {
|
|
acc[country] = data
|
|
return acc
|
|
}, {} as CitiesGroupedByCountry)
|
|
|
|
return groupedCitiesByCountry
|
|
}
|
|
|
|
export async function getLocations({
|
|
lang,
|
|
citiesByCountry,
|
|
serviceToken,
|
|
}: {
|
|
lang: Lang
|
|
citiesByCountry: CitiesGroupedByCountry | null
|
|
serviceToken: string
|
|
}) {
|
|
const cacheClient = await getCacheClient()
|
|
|
|
return await cacheClient.cacheOrGet(
|
|
`${lang}:locations`.toLowerCase(),
|
|
async () => {
|
|
const params = new URLSearchParams({
|
|
language: toApiLang(lang),
|
|
})
|
|
|
|
const apiResponse = await api.get(
|
|
api.endpoints.v1.Hotel.locations,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
params
|
|
)
|
|
if (!apiResponse.ok) {
|
|
if (apiResponse.status === 401) {
|
|
throw new Error("unauthorized")
|
|
} else if (apiResponse.status === 403) {
|
|
throw new Error("forbidden")
|
|
}
|
|
throw new Error("downstream error")
|
|
}
|
|
const apiJson = await apiResponse.json()
|
|
const verifiedLocations = locationsSchema.safeParse(apiJson)
|
|
if (!verifiedLocations.success) {
|
|
console.info(`Locations Verification Failed`)
|
|
console.error(verifiedLocations.error)
|
|
throw new Error("Unable to parse locations")
|
|
}
|
|
|
|
return await Promise.all(
|
|
verifiedLocations.data.data.map(async (location) => {
|
|
if (location.type === "cities") {
|
|
if (citiesByCountry) {
|
|
const country = Object.keys(citiesByCountry).find((country) =>
|
|
citiesByCountry[country].find(
|
|
(loc) => loc.name === location.name
|
|
)
|
|
)
|
|
if (country) {
|
|
return {
|
|
...location,
|
|
country,
|
|
}
|
|
} else {
|
|
console.info(
|
|
`Location cannot be found in any of the countries cities`
|
|
)
|
|
console.info(location)
|
|
}
|
|
}
|
|
} else if (location.type === "hotels") {
|
|
if (location.relationships.city?.url) {
|
|
const city = await getCity({
|
|
cityUrl: location.relationships.city.url,
|
|
serviceToken,
|
|
})
|
|
if (city) {
|
|
return deepmerge(location, {
|
|
relationships: {
|
|
city,
|
|
},
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return location
|
|
})
|
|
)
|
|
},
|
|
"1d"
|
|
)
|
|
}
|
|
|
|
export async function getHotelIdsByCityId({
|
|
cityId,
|
|
serviceToken,
|
|
}: {
|
|
cityId: string
|
|
serviceToken: string
|
|
}) {
|
|
const cacheClient = await getCacheClient()
|
|
return await cacheClient.cacheOrGet(
|
|
`${cityId}:hotelsByCityId`,
|
|
async () => {
|
|
const searchParams = new URLSearchParams({
|
|
city: cityId,
|
|
})
|
|
metrics.hotelIds.counter.add(1, { params: searchParams.toString() })
|
|
console.info(
|
|
"api.hotel.hotel-ids start",
|
|
JSON.stringify({ params: searchParams.toString() })
|
|
)
|
|
|
|
const apiResponse = await api.get(
|
|
api.endpoints.v1.Hotel.hotels,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
searchParams
|
|
)
|
|
|
|
if (!apiResponse.ok) {
|
|
const responseMessage = await apiResponse.text()
|
|
metrics.hotelIds.fail.add(1, {
|
|
params: searchParams.toString(),
|
|
error_type: "http_error",
|
|
error: responseMessage,
|
|
})
|
|
console.error(
|
|
"api.hotel.hotel-ids fetch error",
|
|
JSON.stringify({
|
|
params: searchParams.toString(),
|
|
error: {
|
|
status: apiResponse.status,
|
|
statusText: apiResponse.statusText,
|
|
text: responseMessage,
|
|
},
|
|
})
|
|
)
|
|
|
|
throw new Error("Unable to fetch hotelIds by cityId")
|
|
}
|
|
|
|
const apiJson = await apiResponse.json()
|
|
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
|
|
if (!validatedHotelIds.success) {
|
|
metrics.hotelIds.fail.add(1, {
|
|
params: searchParams.toString(),
|
|
error_type: "validation_error",
|
|
error: JSON.stringify(validatedHotelIds.error),
|
|
})
|
|
console.error(
|
|
"api.hotel.hotel-ids validation error",
|
|
JSON.stringify({
|
|
params: searchParams.toString(),
|
|
error: validatedHotelIds.error,
|
|
})
|
|
)
|
|
|
|
throw new Error("Unable to parse data for hotelIds by cityId")
|
|
}
|
|
|
|
metrics.hotelIds.success.add(1, { cityId })
|
|
console.info(
|
|
"api.hotel.hotel-ids success",
|
|
JSON.stringify({
|
|
params: searchParams.toString(),
|
|
response: validatedHotelIds.data,
|
|
})
|
|
)
|
|
|
|
return validatedHotelIds.data
|
|
},
|
|
env.CACHE_TIME_HOTELS
|
|
)
|
|
}
|
|
|
|
export async function getHotelIdsByCountry({
|
|
country,
|
|
serviceToken,
|
|
}: {
|
|
country: string
|
|
serviceToken: string
|
|
}) {
|
|
const cacheClient = await getCacheClient()
|
|
|
|
return await cacheClient.cacheOrGet(
|
|
`${country}:hotelsByCountry`,
|
|
async () => {
|
|
metrics.hotelIds.counter.add(1, { country })
|
|
console.info(
|
|
"api.hotel.hotel-ids start",
|
|
JSON.stringify({ query: { country } })
|
|
)
|
|
|
|
const hotelIdsParams = new URLSearchParams({
|
|
country,
|
|
})
|
|
|
|
const apiResponse = await api.get(
|
|
api.endpoints.v1.Hotel.hotels,
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
hotelIdsParams
|
|
)
|
|
|
|
if (!apiResponse.ok) {
|
|
const responseMessage = await apiResponse.text()
|
|
metrics.hotelIds.fail.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,
|
|
},
|
|
})
|
|
)
|
|
|
|
throw new Error("Unable to fetch hotelIds by country")
|
|
}
|
|
|
|
const apiJson = await apiResponse.json()
|
|
const validatedHotelIds = getHotelIdsSchema.safeParse(apiJson)
|
|
if (!validatedHotelIds.success) {
|
|
metrics.hotelIds.fail.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,
|
|
})
|
|
)
|
|
throw new Error("Unable to parse hotelIds by country")
|
|
}
|
|
|
|
metrics.hotelIds.success.add(1, { country })
|
|
console.info(
|
|
"api.hotel.hotel-ids success",
|
|
JSON.stringify({ query: { country } })
|
|
)
|
|
|
|
return validatedHotelIds.data
|
|
},
|
|
env.CACHE_TIME_HOTELS
|
|
)
|
|
}
|
|
|
|
export async function getHotelIdsByCityIdentifier(
|
|
cityIdentifier: string,
|
|
serviceToken: string
|
|
) {
|
|
const city = await getCityByCityIdentifier({
|
|
cityIdentifier,
|
|
lang: Lang.en,
|
|
serviceToken,
|
|
})
|
|
|
|
if (!city) {
|
|
return []
|
|
}
|
|
|
|
const hotelIds = await getHotelIdsByCityId({
|
|
cityId: city.id,
|
|
serviceToken,
|
|
})
|
|
return hotelIds
|
|
}
|
|
|
|
export async function getCityByCityIdentifier({
|
|
cityIdentifier,
|
|
lang,
|
|
serviceToken,
|
|
}: {
|
|
cityIdentifier: string
|
|
lang: Lang
|
|
serviceToken: string
|
|
}) {
|
|
const locations = await getLocations({
|
|
lang,
|
|
citiesByCountry: null,
|
|
serviceToken,
|
|
})
|
|
if (!locations || "error" in locations) {
|
|
return null
|
|
}
|
|
|
|
const city = locations
|
|
.filter((loc): loc is CityLocation => loc.type === "cities")
|
|
.find((loc) => loc.cityIdentifier === cityIdentifier)
|
|
|
|
return city ?? null
|
|
}
|
|
|
|
export async function getHotelsByHotelIds({
|
|
hotelIds,
|
|
lang,
|
|
serviceToken,
|
|
}: {
|
|
hotelIds: string[]
|
|
lang: Lang
|
|
serviceToken: string
|
|
}) {
|
|
const cacheClient = await getCacheClient()
|
|
const cacheKey = `${lang}:getHotelsByHotelIds:hotels:${hotelIds.sort().join(",")}`
|
|
|
|
return await cacheClient.cacheOrGet(
|
|
cacheKey,
|
|
async () => {
|
|
const hotelPages = await getHotelPageUrls(lang)
|
|
const hotels = await Promise.all(
|
|
hotelIds.map(async (hotelId) => {
|
|
const hotelResponse = await getHotel(
|
|
{ hotelId, language: lang, isCardOnlyPayment: false },
|
|
serviceToken
|
|
)
|
|
|
|
if (!hotelResponse) {
|
|
throw new Error(`Hotel not found: ${hotelId}`)
|
|
}
|
|
|
|
const hotelPage = hotelPages.find((page) => page.hotelId === hotelId)
|
|
const { hotel, cities } = hotelResponse
|
|
const data: DestinationPagesHotelData = {
|
|
hotel: {
|
|
id: hotel.id,
|
|
galleryImages: hotel.galleryImages?.length
|
|
? [hotel.galleryImages[0]]
|
|
: [],
|
|
name: hotel.name,
|
|
tripadvisor: hotel.ratings?.tripAdvisor?.rating,
|
|
detailedFacilities: hotel.detailedFacilities?.slice(0, 3) || [],
|
|
location: hotel.location,
|
|
hotelType: hotel.hotelType,
|
|
type: hotel.type,
|
|
address: hotel.address,
|
|
cityIdentifier: cities?.[0]?.cityIdentifier,
|
|
hotelDescription: hotel.hotelContent?.texts.descriptions?.short,
|
|
},
|
|
url: hotelPage?.url ?? "",
|
|
}
|
|
|
|
return { ...data, url: hotelPage?.url ?? null }
|
|
})
|
|
)
|
|
|
|
return hotels.filter(
|
|
(hotel): hotel is DestinationPagesHotelData => !!hotel
|
|
)
|
|
},
|
|
"1d"
|
|
)
|
|
}
|
|
|
|
function findProduct(product: Products, rateDefinition: RateDefinition) {
|
|
if ("corporateCheque" in product) {
|
|
return product.corporateCheque.rateCode === rateDefinition.rateCode
|
|
}
|
|
|
|
if (("member" in product && product.member) || "public" in product) {
|
|
let isMemberRate = false
|
|
if (product.member) {
|
|
isMemberRate = product.member.rateCode === rateDefinition.rateCode
|
|
}
|
|
let isPublicRate = false
|
|
if (product.public) {
|
|
isPublicRate = product.public.rateCode === rateDefinition.rateCode
|
|
}
|
|
return isMemberRate || isPublicRate
|
|
}
|
|
|
|
if ("voucher" in product) {
|
|
return product.voucher.rateCode === rateDefinition.rateCode
|
|
}
|
|
|
|
if (Array.isArray(product)) {
|
|
return product.find(
|
|
(r) => r.redemption.rateCode === rateDefinition.rateCode
|
|
)
|
|
}
|
|
}
|
|
|
|
export async function getSelectedRoomAvailability(
|
|
input: z.input<typeof selectedRoomAvailabilityInputSchema>,
|
|
lang: string,
|
|
serviceToken: string,
|
|
userPoints?: number
|
|
) {
|
|
const {
|
|
adults,
|
|
bookingCode,
|
|
children,
|
|
hotelId,
|
|
roomStayEndDate,
|
|
roomStayStartDate,
|
|
redemption,
|
|
} = input
|
|
|
|
const params: Record<string, string | number | undefined> = {
|
|
roomStayStartDate,
|
|
roomStayEndDate,
|
|
adults,
|
|
...(children && { children }),
|
|
...(bookingCode && { bookingCode }),
|
|
...(redemption && { isRedemption: "true" }),
|
|
language: lang,
|
|
}
|
|
|
|
metrics.selectedRoomAvailability.counter.add(1, input)
|
|
console.info(
|
|
"api.hotels.selectedRoomAvailability start",
|
|
JSON.stringify({ query: { hotelId: input.hotelId, params } })
|
|
)
|
|
const apiResponseAvailability = await api.get(
|
|
api.endpoints.v1.Availability.hotel(hotelId.toString()),
|
|
{
|
|
headers: {
|
|
Authorization: `Bearer ${serviceToken}`,
|
|
},
|
|
},
|
|
params
|
|
)
|
|
|
|
if (!apiResponseAvailability.ok) {
|
|
const text = await apiResponseAvailability.text()
|
|
metrics.selectedRoomAvailability.fail.add(1, {
|
|
hotelId,
|
|
roomStayStartDate,
|
|
roomStayEndDate,
|
|
adults,
|
|
children,
|
|
bookingCode,
|
|
error_type: "http_error",
|
|
error: JSON.stringify({
|
|
status: apiResponseAvailability.status,
|
|
statusText: apiResponseAvailability.statusText,
|
|
text,
|
|
}),
|
|
})
|
|
console.error(
|
|
"api.hotels.selectedRoomAvailability error",
|
|
JSON.stringify({
|
|
query: { hotelId, params },
|
|
error: {
|
|
status: apiResponseAvailability.status,
|
|
statusText: apiResponseAvailability.statusText,
|
|
text,
|
|
},
|
|
})
|
|
)
|
|
|
|
throw new Error("Failed to fetch selected room availability")
|
|
}
|
|
const apiJsonAvailability = await apiResponseAvailability.json()
|
|
const validateAvailabilityData =
|
|
roomsAvailabilitySchema.safeParse(apiJsonAvailability)
|
|
if (!validateAvailabilityData.success) {
|
|
metrics.selectedRoomAvailability.fail.add(1, {
|
|
hotelId,
|
|
roomStayStartDate,
|
|
roomStayEndDate,
|
|
adults,
|
|
children,
|
|
bookingCode,
|
|
error_type: "validation_error",
|
|
error: JSON.stringify(validateAvailabilityData.error),
|
|
})
|
|
console.error(
|
|
"api.hotels.selectedRoomAvailability validation error",
|
|
JSON.stringify({
|
|
query: { hotelId, params },
|
|
error: validateAvailabilityData.error,
|
|
})
|
|
)
|
|
throw badRequestError()
|
|
}
|
|
|
|
const { rateDefinitions, roomConfigurations } = validateAvailabilityData.data
|
|
|
|
const rateDefinition = rateDefinitions.find(
|
|
(rd) => rd.rateCode === input.rateCode
|
|
)
|
|
if (!rateDefinition) {
|
|
return null
|
|
}
|
|
|
|
const selectedRoom = roomConfigurations.find(
|
|
(room) =>
|
|
room.roomTypeCode === input.roomTypeCode &&
|
|
room.products.find((product) => findProduct(product, rateDefinition))
|
|
)
|
|
|
|
if (!selectedRoom) {
|
|
return null
|
|
}
|
|
|
|
let product: Product | RedemptionsProduct | undefined =
|
|
selectedRoom.products.find((product) =>
|
|
findProduct(product, rateDefinition)
|
|
)
|
|
|
|
if (!product) {
|
|
return null
|
|
}
|
|
|
|
if (Array.isArray(product)) {
|
|
const redemptionProduct = userPoints
|
|
? product.find(
|
|
(r) =>
|
|
r.redemption.rateCode === rateDefinition.rateCode &&
|
|
r.redemption.localPrice.pointsPerStay <= userPoints
|
|
)
|
|
: undefined
|
|
if (!redemptionProduct) {
|
|
return null
|
|
}
|
|
product = redemptionProduct
|
|
}
|
|
|
|
return {
|
|
rateDefinition,
|
|
rateDefinitions,
|
|
rooms: roomConfigurations,
|
|
product,
|
|
selectedRoom,
|
|
}
|
|
}
|