This creates the alternative hotels page. It is mostly a copy of the select hotel page, and most of the contents of the pages lives under the same component in /components.

Merged in feat/sw-397-alternative-hotels (pull request #1211)

Feat/sw 397 alternative hotels

* fix(SW-397): create alternative hotels page

* update types

* Adapt to new changes for fetching data

* Make bookingcode optional

* Code review fixes


Approved-by: Simon.Emanuelsson
This commit is contained in:
Niclas Edenvin
2025-01-28 12:08:40 +00:00
parent 4247e37667
commit ef22fc4627
28 changed files with 693 additions and 105 deletions

View File

@@ -0,0 +1,11 @@
.layout {
min-height: 100dvh;
background-color: var(--Base-Background-Primary-Normal);
position: relative;
}
@media screen and (min-width: 768px) {
.layout {
z-index: 0;
}
}

View File

@@ -0,0 +1,7 @@
import styles from "./layout.module.css"
export default function HotelReservationLayout({
children,
}: React.PropsWithChildren) {
return <div className={styles.layout}>{children}</div>
}

View File

@@ -0,0 +1,6 @@
.main {
display: grid;
background-color: var(--Scandic-Brand-Warm-White);
min-height: 100dvh;
position: relative;
}

View File

@@ -0,0 +1,37 @@
import { notFound } from "next/navigation"
import { Suspense } from "react"
import { SelectHotelMapContainer } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainer"
import { SelectHotelMapContainerSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelMap/SelectHotelMapContainerSkeleton"
import { MapContainer } from "@/components/MapContainer"
import { setLang } from "@/i18n/serverContext"
import { getHotelSearchDetails } from "../../utils"
import styles from "./page.module.css"
import type { AlternativeHotelsSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function SelectHotelMapPage({
params,
searchParams,
}: PageArgs<LangParams, AlternativeHotelsSearchParams>) {
setLang(params.lang)
return (
<div className={styles.main}>
<MapContainer>
<Suspense
key={searchParams.hotel}
fallback={<SelectHotelMapContainerSkeleton />}
>
<SelectHotelMapContainer
searchParams={searchParams}
isAlternativeHotels
/>
</Suspense>
</MapContainer>
</div>
)
}

View File

@@ -0,0 +1,33 @@
import { Suspense } from "react"
import SelectHotel from "@/components/HotelReservation/SelectHotel"
import { SelectHotelSkeleton } from "@/components/HotelReservation/SelectHotel/SelectHotelSkeleton"
import { setLang } from "@/i18n/serverContext"
import type { AlternativeHotelsSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { LangParams, PageArgs } from "@/types/params"
export default async function AlternativeHotelsPage({
params,
searchParams,
}: PageArgs<LangParams, AlternativeHotelsSearchParams>) {
setLang(params.lang)
const roomKey = Object.keys(searchParams)
.filter((key) => key.startsWith("room["))
.map((key) => searchParams[key])
.join("-")
return (
<Suspense
key={`${searchParams.hotel}-${searchParams.fromDate}-${searchParams.toDate}-${roomKey}`}
fallback={<SelectHotelSkeleton />}
>
<SelectHotel
params={params}
searchParams={searchParams}
isAlternativeHotels
/>
</Suspense>
)
}

View File

@@ -3,7 +3,10 @@ import { serverClient } from "@/lib/trpc/server"
import { getLang } from "@/i18n/serverContext" import { getLang } from "@/i18n/serverContext"
import type { AvailabilityInput } from "@/types/components/hotelReservation/selectHotel/availabilityInput" import type {
AlternativeHotelsAvailabilityInput,
AvailabilityInput,
} from "@/types/components/hotelReservation/selectHotel/availabilityInput"
import type { import type {
HotelData, HotelData,
NullableHotelData, NullableHotelData,
@@ -12,6 +15,7 @@ import type {
CategorizedFilters, CategorizedFilters,
Filter, Filter,
} from "@/types/components/hotelReservation/selectHotel/hotelFilters" } from "@/types/components/hotelReservation/selectHotel/hotelFilters"
import type { HotelsAvailabilityItem } from "@/server/routers/hotels/output"
const hotelSurroundingsFilterNames = [ const hotelSurroundingsFilterNames = [
"Hotel surroundings", "Hotel surroundings",
@@ -34,13 +38,41 @@ const hotelFacilitiesFilterNames = [
export async function fetchAvailableHotels( export async function fetchAvailableHotels(
input: AvailabilityInput input: AvailabilityInput
): Promise<NullableHotelData[]> { ): Promise<NullableHotelData[]> {
const availableHotels = await serverClient().hotel.availability.hotels(input) const availableHotels =
await serverClient().hotel.availability.hotelsByCity(input)
if (!availableHotels) return [] if (!availableHotels) return []
return enhanceHotels(availableHotels)
}
export async function fetchAlternativeHotels(
hotelId: string,
input: AlternativeHotelsAvailabilityInput
): Promise<NullableHotelData[]> {
const alternativeHotelIds = await serverClient().hotel.nearbyHotelIds({
hotelId,
})
if (!alternativeHotelIds) return []
const availableHotels =
await serverClient().hotel.availability.hotelsByHotelIds({
...input,
hotelIds: alternativeHotelIds,
})
if (!availableHotels) return []
return enhanceHotels(availableHotels)
}
async function enhanceHotels(hotels: {
availability: HotelsAvailabilityItem[]
}) {
const language = getLang() const language = getLang()
const hotels = availableHotels.availability.map(async (hotel) => { const hotelFetchers = hotels.availability.map(async (hotel) => {
const hotelData = await getHotelData({ const hotelData = await getHotelData({
hotelId: hotel.hotelId.toString(), hotelId: hotel.hotelId.toString(),
language, language,
@@ -54,7 +86,7 @@ export async function fetchAvailableHotels(
} }
}) })
return await Promise.all(hotels) return await Promise.all(hotelFetchers)
} }
export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters { export function getFiltersFromHotels(hotels: HotelData[]): CategorizedFilters {

View File

@@ -3,49 +3,81 @@ import { notFound } from "next/navigation"
import { getLocations } from "@/lib/trpc/memoizedRequests" import { getLocations } from "@/lib/trpc/memoizedRequests"
import { generateChildrenString } from "@/components/HotelReservation/utils" import { generateChildrenString } from "@/components/HotelReservation/utils"
import { convertSearchParamsToObj } from "@/utils/url" import { convertSearchParamsToObj, type SelectHotelParams } from "@/utils/url"
import type { SelectHotelSearchParams } from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams" import type {
AlternativeHotelsSearchParams,
SelectHotelSearchParams,
} from "@/types/components/hotelReservation/selectHotel/selectHotelSearchParams"
import type { import type {
Child, Child,
SelectRateSearchParams, SelectRateSearchParams,
} from "@/types/components/hotelReservation/selectRate/selectRate" } from "@/types/components/hotelReservation/selectRate/selectRate"
import type { Location } from "@/types/trpc/routers/hotel/locations" import {
type HotelLocation,
isHotelLocation,
type Location,
} from "@/types/trpc/routers/hotel/locations"
interface HotelSearchDetails<T> { interface HotelSearchDetails<T> {
city: Location | null city: Location | null
hotel: Location | null hotel: HotelLocation | null
selectHotelParams: T selectHotelParams: SelectHotelParams<T> & { city: string | undefined }
adultsInRoom: number adultsInRoom: number
childrenInRoomString?: string childrenInRoomString?: string
childrenInRoom?: Child[] childrenInRoom?: Child[]
} }
export async function getHotelSearchDetails< export async function getHotelSearchDetails<
T extends SelectHotelSearchParams | SelectRateSearchParams, T extends
>({ | SelectHotelSearchParams
searchParams, | SelectRateSearchParams
}: { | AlternativeHotelsSearchParams,
searchParams: T & { >(
[key: string]: string {
} searchParams,
}): Promise<HotelSearchDetails<T> | null> { }: {
searchParams: T & {
[key: string]: string
}
},
isAlternativeHotels?: boolean
): Promise<HotelSearchDetails<T> | null> {
const selectHotelParams = convertSearchParamsToObj<T>(searchParams) const selectHotelParams = convertSearchParamsToObj<T>(searchParams)
const locations = await getLocations() const locations = await getLocations()
if (!locations || "error" in locations) return null if (!locations || "error" in locations) return null
const city = locations.data.find( const hotel =
(location) => ("hotelId" in selectHotelParams &&
location.name.toLowerCase() === selectHotelParams.city?.toLowerCase() (locations.data.find(
) (location) =>
const hotel = locations.data.find( isHotelLocation(location) &&
(location) => "operaId" in location &&
"operaId" in location && location.operaId == selectHotelParams.hotelId location.operaId === selectHotelParams.hotelId
) ) as HotelLocation | undefined)) ||
null
if (isAlternativeHotels && !hotel) {
return notFound()
}
const cityName = isAlternativeHotels
? hotel?.relationships.city.name
: "city" in selectHotelParams
? (selectHotelParams.city as string | undefined)
: undefined
const city =
(typeof cityName === "string" &&
locations.data.find(
(location) => location.name.toLowerCase() === cityName.toLowerCase()
)) ||
null
if (!city && !hotel) return notFound() if (!city && !hotel) return notFound()
if (isAlternativeHotels && (!city || !hotel)) return notFound()
let adultsInRoom = 1 let adultsInRoom = 1
let childrenInRoomString: HotelSearchDetails<T>["childrenInRoomString"] = let childrenInRoomString: HotelSearchDetails<T>["childrenInRoomString"] =
@@ -63,9 +95,9 @@ export async function getHotelSearchDetails<
} }
return { return {
city: city ?? null, city,
hotel: hotel ?? null, hotel,
selectHotelParams, selectHotelParams: { city: cityName, ...selectHotelParams },
adultsInRoom, adultsInRoom,
childrenInRoomString, childrenInRoomString,
childrenInRoom, childrenInRoom,

View File

@@ -77,7 +77,7 @@ export default function BookingWidgetClient({
const selectedLocation = bookingWidgetSearchData const selectedLocation = bookingWidgetSearchData
? getLocationObj( ? getLocationObj(
(bookingWidgetSearchData.hotel ?? (bookingWidgetSearchData.hotelId ??
bookingWidgetSearchData.city) as string bookingWidgetSearchData.city) as string
) )
: undefined : undefined

View File

@@ -2,7 +2,10 @@
import { useIntl } from "react-intl" import { useIntl } from "react-intl"
import { selectHotelMap } from "@/constants/routes/hotelReservation" import {
alternativeHotelsMap,
selectHotelMap,
} from "@/constants/routes/hotelReservation"
import { MapIcon } from "@/components/Icons" import { MapIcon } from "@/components/Icons"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
@@ -17,8 +20,10 @@ import type { CategorizedFilters } from "@/types/components/hotelReservation/sel
export default function MobileMapButtonContainer({ export default function MobileMapButtonContainer({
filters, filters,
isAlternative,
}: { }: {
filters: CategorizedFilters filters: CategorizedFilters
isAlternative?: boolean
}) { }) {
const intl = useIntl() const intl = useIntl()
const lang = useLang() const lang = useLang()
@@ -33,7 +38,9 @@ export default function MobileMapButtonContainer({
size="small" size="small"
> >
<Link <Link
href={selectHotelMap(lang)} href={
isAlternative ? alternativeHotelsMap(lang) : selectHotelMap(lang)
}
color="baseButtonTextOnFillNormal" color="baseButtonTextOnFillNormal"
keepSearchParams keepSearchParams
weight="bold" weight="bold"

View File

@@ -0,0 +1,48 @@
import { alternativeHotels } from "@/constants/routes/hotelReservation"
import Alert from "@/components/TempDesignSystem/Alert"
import { getIntl } from "@/i18n"
import { getLang } from "@/i18n/serverContext"
import type { NoAvailabilityAlertProp } from "@/types/components/hotelReservation/selectHotel/noAvailabilityAlert"
import { AlertTypeEnum } from "@/types/enums/alert"
export default async function NoAvailabilityAlert({
hotels,
isAllUnavailable,
isAlternative,
}: NoAvailabilityAlertProp) {
const intl = await getIntl()
const lang = getLang()
if (!isAllUnavailable) {
return null
}
if (hotels.length === 1 && !isAlternative) {
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "Please try and change your search for this destination or see alternative hotels.",
})}
link={{
title: intl.formatMessage({ id: "See alternative hotels" }),
url: `${alternativeHotels(lang)}?hotel=${hotels[0].hotelData.operaId}`,
keepSearchParams: true,
}}
/>
)
}
return (
<Alert
type={AlertTypeEnum.Info}
heading={intl.formatMessage({ id: "No availability" })}
text={intl.formatMessage({
id: "There are no rooms available that match your request.",
})}
/>
)
}

View File

@@ -6,6 +6,7 @@ import { env } from "@/env/server"
import { getCityCoordinates } from "@/lib/trpc/memoizedRequests" import { getCityCoordinates } from "@/lib/trpc/memoizedRequests"
import { import {
fetchAlternativeHotels,
fetchAvailableHotels, fetchAvailableHotels,
getFiltersFromHotels, getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
@@ -36,16 +37,20 @@ function isValidHotelData(hotel: NullableHotelData): hotel is HotelData {
export async function SelectHotelMapContainer({ export async function SelectHotelMapContainer({
searchParams, searchParams,
isAlternativeHotels,
}: SelectHotelMapContainerProps) { }: SelectHotelMapContainerProps) {
const lang = getLang() const lang = getLang()
const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID const googleMapId = env.GOOGLE_DYNAMIC_MAP_ID
const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY const googleMapsApiKey = env.GOOGLE_STATIC_MAP_KEY
const getHotelSearchDetailsPromise = safeTry( const getHotelSearchDetailsPromise = safeTry(
getHotelSearchDetails({ getHotelSearchDetails(
searchParams: searchParams as SelectHotelSearchParams & { {
[key: string]: string searchParams: searchParams as SelectHotelSearchParams & {
[key: string]: string
},
}, },
}) isAlternativeHotels
)
) )
const [searchDetails] = await getHotelSearchDetailsPromise const [searchDetails] = await getHotelSearchDetailsPromise
@@ -58,19 +63,29 @@ export async function SelectHotelMapContainer({
adultsInRoom, adultsInRoom,
childrenInRoom, childrenInRoom,
childrenInRoomString, childrenInRoomString,
hotel: isAlternativeFor,
} = searchDetails } = searchDetails
if (!city) return notFound() if (!city) return notFound()
const fetchAvailableHotelsPromise = safeTry( const fetchAvailableHotelsPromise = isAlternativeFor
fetchAvailableHotels({ ? safeTry(
cityId: city.id, fetchAlternativeHotels(isAlternativeFor.id, {
roomStayStartDate: selectHotelParams.fromDate, roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate, roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom, adults: adultsInRoom,
children: childrenInRoomString, children: childrenInRoomString,
}) })
) )
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom,
children: childrenInRoomString,
})
)
const [hotels] = await fetchAvailableHotelsPromise const [hotels] = await fetchAvailableHotelsPromise
@@ -87,18 +102,24 @@ export async function SelectHotelMapContainer({
const departureDate = new Date(selectHotelParams.toDate) const departureDate = new Date(selectHotelParams.toDate)
const pageTrackingData: TrackingSDKPageData = { const pageTrackingData: TrackingSDKPageData = {
pageId: "select-hotel", pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
domainLanguage: lang, domainLanguage: lang,
channel: TrackingChannelEnum["hotelreservation"], channel: TrackingChannelEnum["hotelreservation"],
pageName: "hotelreservation|select-hotel|mapview", pageName: isAlternativeHotels
siteSections: "hotelreservation|select-hotel|mapview", ? "hotelreservation|alternative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
siteSections: isAlternativeHotels
? "hotelreservation|altervative-hotels|mapview"
: "hotelreservation|select-hotel|mapview",
pageType: "bookinghotelsmapviewpage", pageType: "bookinghotelsmapviewpage",
siteVersion: "new-web", siteVersion: "new-web",
} }
const hotelsTrackingData: TrackingSDKHotelInfo = { const hotelsTrackingData: TrackingSDKHotelInfo = {
availableResults: validHotels.length, availableResults: validHotels.length,
searchTerm: selectHotelParams.city, searchTerm: isAlternativeFor
? selectHotelParams.hotelId
: (selectHotelParams.city as string),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"), arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom, noOfAdults: adultsInRoom,

View File

@@ -3,18 +3,20 @@ import { notFound } from "next/navigation"
import { Suspense } from "react" import { Suspense } from "react"
import { import {
alternativeHotels,
alternativeHotelsMap,
selectHotel, selectHotel,
selectHotelMap, selectHotelMap,
} from "@/constants/routes/hotelReservation" } from "@/constants/routes/hotelReservation"
import { import {
fetchAlternativeHotels,
fetchAvailableHotels, fetchAvailableHotels,
getFiltersFromHotels, getFiltersFromHotels,
} from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils" } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/select-hotel/utils"
import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils" import { getHotelSearchDetails } from "@/app/[lang]/(live)/(public)/hotelreservation/(standard)/utils"
import { ChevronRightIcon } from "@/components/Icons" import { ChevronRightIcon } from "@/components/Icons"
import StaticMap from "@/components/Maps/StaticMap" import StaticMap from "@/components/Maps/StaticMap"
import Alert from "@/components/TempDesignSystem/Alert"
import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs" import Breadcrumbs from "@/components/TempDesignSystem/Breadcrumbs"
import Button from "@/components/TempDesignSystem/Button" import Button from "@/components/TempDesignSystem/Button"
import Link from "@/components/TempDesignSystem/Link" import Link from "@/components/TempDesignSystem/Link"
@@ -29,6 +31,7 @@ import HotelCount from "./HotelCount"
import HotelFilter from "./HotelFilter" import HotelFilter from "./HotelFilter"
import HotelSorter from "./HotelSorter" import HotelSorter from "./HotelSorter"
import MobileMapButtonContainer from "./MobileMapButtonContainer" import MobileMapButtonContainer from "./MobileMapButtonContainer"
import NoAvailabilityAlert from "./NoAvailabilityAlert"
import styles from "./selectHotel.module.css" import styles from "./selectHotel.module.css"
@@ -41,20 +44,23 @@ import {
type TrackingSDKHotelInfo, type TrackingSDKHotelInfo,
type TrackingSDKPageData, type TrackingSDKPageData,
} from "@/types/components/tracking" } from "@/types/components/tracking"
import { AlertTypeEnum } from "@/types/enums/alert"
export default async function SelectHotel({ export default async function SelectHotel({
params, params,
searchParams, searchParams,
isAlternativeHotels,
}: SelectHotelProps) { }: SelectHotelProps) {
const intl = await getIntl() const intl = await getIntl()
const getHotelSearchDetailsPromise = safeTry( const getHotelSearchDetailsPromise = safeTry(
getHotelSearchDetails({ getHotelSearchDetails(
searchParams: searchParams as SelectHotelSearchParams & { {
[key: string]: string searchParams: searchParams as SelectHotelSearchParams & {
[key: string]: string
},
}, },
}) isAlternativeHotels
)
) )
const [searchDetails] = await getHotelSearchDetailsPromise const [searchDetails] = await getHotelSearchDetailsPromise
@@ -67,19 +73,29 @@ export default async function SelectHotel({
adultsInRoom, adultsInRoom,
childrenInRoomString, childrenInRoomString,
childrenInRoom, childrenInRoom,
hotel: isAlternativeFor,
} = searchDetails } = searchDetails
if (!city) return notFound() if (!city) return notFound()
const hotelsPromise = safeTry( const hotelsPromise = isAlternativeFor
fetchAvailableHotels({ ? safeTry(
cityId: city.id, fetchAlternativeHotels(isAlternativeFor.id, {
roomStayStartDate: selectHotelParams.fromDate, roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate, roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom, adults: adultsInRoom,
children: childrenInRoomString, children: childrenInRoomString,
}) })
) )
: safeTry(
fetchAvailableHotels({
cityId: city.id,
roomStayStartDate: selectHotelParams.fromDate,
roomStayEndDate: selectHotelParams.toDate,
adults: adultsInRoom,
children: childrenInRoomString,
})
)
const [hotels] = await hotelsPromise const [hotels] = await hotelsPromise
@@ -105,32 +121,50 @@ export default async function SelectHotel({
href: `/${params.lang}/hotelreservation`, href: `/${params.lang}/hotelreservation`,
uid: "hotel-reservation", uid: "hotel-reservation",
}, },
{ isAlternativeFor
title: intl.formatMessage({ id: "Select hotel" }), ? {
href: `${selectHotel(params.lang)}/?${convertedSearchParams}`, title: intl.formatMessage({ id: "Alternative hotels" }),
uid: "select-hotel", href: `${alternativeHotels(params.lang)}/?${convertedSearchParams}`,
}, uid: "alternative-hotels",
{ }
title: city.name, : {
uid: city.id, title: intl.formatMessage({ id: "Select hotel" }),
}, href: `${selectHotel(params.lang)}/?${convertedSearchParams}`,
uid: "select-hotel",
},
isAlternativeFor
? {
title: isAlternativeFor.name,
uid: isAlternativeFor.id,
}
: {
title: city.name,
uid: city.id,
},
] ]
const isAllUnavailable = hotels?.every((hotel) => hotel.price === undefined) const isAllUnavailable =
hotels?.every((hotel) => hotel.price === undefined) || false
const pageTrackingData: TrackingSDKPageData = { const pageTrackingData: TrackingSDKPageData = {
pageId: "select-hotel", pageId: isAlternativeFor ? "alternative-hotels" : "select-hotel",
domainLanguage: params.lang, domainLanguage: params.lang,
channel: TrackingChannelEnum["hotelreservation"], channel: TrackingChannelEnum["hotelreservation"],
pageName: "hotelreservation|select-hotel", pageName: isAlternativeFor
siteSections: "hotelreservation|select-hotel", ? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
siteSections: isAlternativeFor
? "hotelreservation|alternative-hotels"
: "hotelreservation|select-hotel",
pageType: "bookinghotelspage", pageType: "bookinghotelspage",
siteVersion: "new-web", siteVersion: "new-web",
} }
const hotelsTrackingData: TrackingSDKHotelInfo = { const hotelsTrackingData: TrackingSDKHotelInfo = {
availableResults: validHotels.length, availableResults: validHotels.length,
searchTerm: selectHotelParams.city, searchTerm: isAlternativeFor
? selectHotelParams.hotelId
: (selectHotelParams.city as string),
arrivalDate: format(arrivalDate, "yyyy-MM-dd"), arrivalDate: format(arrivalDate, "yyyy-MM-dd"),
departureDate: format(departureDate, "yyyy-MM-dd"), departureDate: format(departureDate, "yyyy-MM-dd"),
noOfAdults: adultsInRoom, noOfAdults: adultsInRoom,
@@ -155,7 +189,11 @@ export default async function SelectHotel({
<Breadcrumbs breadcrumbs={breadcrumbs} /> <Breadcrumbs breadcrumbs={breadcrumbs} />
<div className={styles.title}> <div className={styles.title}>
<div className={styles.cityInformation}> <div className={styles.cityInformation}>
<Subtitle>{city.name}</Subtitle> <Subtitle>
{isAlternativeFor
? `${intl.formatMessage({ id: "Alternatives for" })} ${isAlternativeFor.name}`
: city.name}
</Subtitle>
<HotelCount /> <HotelCount />
</div> </div>
<div className={styles.sorter}> <div className={styles.sorter}>
@@ -171,18 +209,22 @@ export default async function SelectHotel({
<Link <Link
className={styles.link} className={styles.link}
color="burgundy" color="burgundy"
href={selectHotelMap(params.lang)} href={
isAlternativeFor
? alternativeHotelsMap(params.lang)
: selectHotelMap(params.lang)
}
keepSearchParams keepSearchParams
> >
<div className={styles.mapContainer}> <div className={styles.mapContainer}>
<StaticMap <StaticMap
city={selectHotelParams.city} city={city.name}
country={isCityWithCountry(city) ? city.country : undefined} country={isCityWithCountry(city) ? city.country : undefined}
width={340} width={340}
height={180} height={180}
zoomLevel={11} zoomLevel={11}
mapType="roadmap" mapType="roadmap"
altText={`Map of ${selectHotelParams.city} city center`} altText={`Map of ${city.name} city center`}
/> />
<Button wrapping size="medium" intent="text" theme="base"> <Button wrapping size="medium" intent="text" theme="base">
{intl.formatMessage({ id: "See map" })} {intl.formatMessage({ id: "See map" })}
@@ -197,27 +239,23 @@ export default async function SelectHotel({
) : ( ) : (
<div className={styles.mapContainer}> <div className={styles.mapContainer}>
<StaticMap <StaticMap
city={selectHotelParams.city} city={city.name}
width={340} width={340}
height={180} height={180}
zoomLevel={11} zoomLevel={11}
mapType="roadmap" mapType="roadmap"
altText={`Map of ${selectHotelParams.city} city center`} altText={`Map of ${city.name} city center`}
/> />
</div> </div>
)} )}
<HotelFilter filters={filterList} className={styles.filter} /> <HotelFilter filters={filterList} className={styles.filter} />
</div> </div>
<div className={styles.hotelList}> <div className={styles.hotelList}>
{isAllUnavailable && ( <NoAvailabilityAlert
<Alert isAlternative={!!isAlternativeFor}
type={AlertTypeEnum.Info} hotels={validHotels}
heading={intl.formatMessage({ id: "No availability" })} isAllUnavailable={isAllUnavailable}
text={intl.formatMessage({ />
id: "There are no rooms available that match your request.",
})}
/>
)}
<HotelCardListing hotelData={validHotels} /> <HotelCardListing hotelData={validHotels} />
</div> </div>
</main> </main>

View File

@@ -19,5 +19,6 @@ export interface AlertProps extends VariantProps<typeof alertVariants> {
link?: { link?: {
url: string url: string
title: string title: string
keepSearchParams?: boolean
} | null } | null
} }

View File

@@ -64,7 +64,12 @@ export default function Alert({
) : null} ) : null}
</div> </div>
{link ? ( {link ? (
<Link color="burgundy" textDecoration="underline" href={link.url}> <Link
color="burgundy"
textDecoration="underline"
href={link.url}
keepSearchParams={link.keepSearchParams}
>
{link.title} {link.title}
</Link> </Link>
) : null} ) : null}

View File

@@ -57,3 +57,17 @@ export function selectHotelMap(lang) {
export function selectRate(lang) { export function selectRate(lang) {
return `${hotelreservation(lang)}/select-rate` return `${hotelreservation(lang)}/select-rate`
} }
/**
* @param {Lang} lang
*/
export function alternativeHotels(lang) {
return `${hotelreservation(lang)}/alternative-hotels`
}
/**
* @param {Lang} lang
*/
export function alternativeHotelsMap(lang) {
return `${hotelreservation(lang)}/alternative-hotels/map`
}

View File

@@ -28,6 +28,7 @@
"All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.", "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.": "All our breakfast buffets offer gluten free, vegan, and allergy-friendly options.",
"Allergy Room": "Allergy room", "Allergy Room": "Allergy room",
"Already a friend?": "Already a friend?", "Already a friend?": "Already a friend?",
"Alternatives for": "Alternatives for",
"Always open": "Always open", "Always open": "Always open",
"Amenities": "Amenities", "Amenities": "Amenities",
"Amusement park": "Amusement park", "Amusement park": "Amusement park",
@@ -369,6 +370,7 @@
"Phone number": "Phone number", "Phone number": "Phone number",
"Please enter a valid phone number": "Please enter a valid phone number", "Please enter a valid phone number": "Please enter a valid phone number",
"Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.", "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.": "Please note that this is mandatory, and that your card will only be charged in the event of a no-show.",
"Please try and change your search for this destination or see alternative hotels.": "Please try and change your search for this destination or see alternative hotels.",
"Points": "Points", "Points": "Points",
"Points being calculated": "Points being calculated", "Points being calculated": "Points being calculated",
"Points earned prior to May 1, 2021": "Points earned prior to May 1, 2021", "Points earned prior to May 1, 2021": "Points earned prior to May 1, 2021",
@@ -432,6 +434,7 @@
"Search": "Search", "Search": "Search",
"See all FAQ": "See all FAQ", "See all FAQ": "See all FAQ",
"See all photos": "See all photos", "See all photos": "See all photos",
"See alternative hotels": "See alternative hotels",
"See details": "See details", "See details": "See details",
"See hotel details": "See hotel details", "See hotel details": "See hotel details",
"See less FAQ": "See less FAQ", "See less FAQ": "See less FAQ",

View File

@@ -38,6 +38,9 @@ export namespace endpoints {
export function city(cityId: string) { export function city(cityId: string) {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/city/${cityId}` return `${base.path.availability}/${version}/${base.enitity.Availabilities}/city/${cityId}`
} }
export function hotels() {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel`
}
export function hotel(hotelId: string) { export function hotel(hotelId: string) {
return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel/${hotelId}` return `${base.path.availability}/${version}/${base.enitity.Availabilities}/hotel/${hotelId}`
} }

View File

@@ -12,6 +12,15 @@ export const getHotelsAvailabilityInputSchema = z.object({
bookingCode: z.string().optional().default(""), bookingCode: z.string().optional().default(""),
}) })
export const getHotelsByHotelIdsAvailabilityInputSchema = z.object({
hotelIds: z.array(z.number()),
roomStayStartDate: z.string(),
roomStayEndDate: z.string(),
adults: z.number(),
children: z.string().optional(),
bookingCode: z.string().optional().default(""),
})
export const getRoomsAvailabilityInputSchema = z.object({ export const getRoomsAvailabilityInputSchema = z.object({
hotelId: z.number(), hotelId: z.number(),
roomStayStartDate: z.string(), roomStayStartDate: z.string(),
@@ -66,6 +75,10 @@ export const getHotelsInput = z.object({
}) })
export interface GetHotelsInput extends z.infer<typeof getHotelsInput> {} export interface GetHotelsInput extends z.infer<typeof getHotelsInput> {}
export const nearbyHotelIdsInput = z.object({
hotelId: z.string(),
})
export const getBreakfastPackageInputSchema = z.object({ export const getBreakfastPackageInputSchema = z.object({
adults: z.number().min(1, { message: "at least one adult is required" }), adults: z.number().min(1, { message: "at least one adult is required" }),
fromDate: z fromDate: z

View File

@@ -542,6 +542,8 @@ export type HotelsAvailability = z.infer<typeof hotelsAvailabilitySchema>
export type ProductType = export type ProductType =
HotelsAvailability["data"][number]["attributes"]["productType"] HotelsAvailability["data"][number]["attributes"]["productType"]
export type ProductTypePrices = z.infer<typeof productTypePriceSchema> export type ProductTypePrices = z.infer<typeof productTypePriceSchema>
export type HotelsAvailabilityItem =
HotelsAvailability["data"][number]["attributes"]
const roomConfigurationSchema = z.object({ const roomConfigurationSchema = z.object({
status: z.string(), status: z.string(),
@@ -889,6 +891,17 @@ export const getHotelIdsByCityIdSchema = z
}) })
.transform((data) => data.data.map((hotel) => hotel.id)) .transform((data) => data.data.map((hotel) => hotel.id))
export const getNearbyHotelIdsSchema = 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))
export const getMeetingRoomsSchema = z.object({ export const getMeetingRoomsSchema = z.object({
data: z.array( data: z.array(
z.object({ z.object({

View File

@@ -23,6 +23,7 @@ import {
getCityCoordinatesInputSchema, getCityCoordinatesInputSchema,
getHotelDataInputSchema, getHotelDataInputSchema,
getHotelsAvailabilityInputSchema, getHotelsAvailabilityInputSchema,
getHotelsByHotelIdsAvailabilityInputSchema,
getHotelsInput, getHotelsInput,
getMeetingRoomsInputSchema, getMeetingRoomsInputSchema,
getRatesInputSchema, getRatesInputSchema,
@@ -30,12 +31,14 @@ import {
getRoomsAvailabilityInputSchema, getRoomsAvailabilityInputSchema,
getSelectedRoomAvailabilityInputSchema, getSelectedRoomAvailabilityInputSchema,
type HotelDataInput, type HotelDataInput,
nearbyHotelIdsInput,
} from "./input" } from "./input"
import { import {
breakfastPackagesSchema, breakfastPackagesSchema,
getHotelDataSchema, getHotelDataSchema,
getHotelsAvailabilitySchema, getHotelsAvailabilitySchema,
getMeetingRoomsSchema, getMeetingRoomsSchema,
getNearbyHotelIdsSchema,
getRatesSchema, getRatesSchema,
getRoomPackagesSchema, getRoomPackagesSchema,
getRoomsAvailabilitySchema, getRoomsAvailabilitySchema,
@@ -59,9 +62,15 @@ import {
hotelsAvailabilityCounter, hotelsAvailabilityCounter,
hotelsAvailabilityFailCounter, hotelsAvailabilityFailCounter,
hotelsAvailabilitySuccessCounter, hotelsAvailabilitySuccessCounter,
hotelsByHotelIdAvailabilityCounter,
hotelsByHotelIdAvailabilityFailCounter,
hotelsByHotelIdAvailabilitySuccessCounter,
meetingRoomsCounter, meetingRoomsCounter,
meetingRoomsFailCounter, meetingRoomsFailCounter,
meetingRoomsSuccessCounter, meetingRoomsSuccessCounter,
nearbyHotelIdsCounter,
nearbyHotelIdsFailCounter,
nearbyHotelIdsSuccessCounter,
roomsAvailabilityCounter, roomsAvailabilityCounter,
roomsAvailabilityFailCounter, roomsAvailabilityFailCounter,
roomsAvailabilitySuccessCounter, roomsAvailabilitySuccessCounter,
@@ -204,7 +213,7 @@ export const getHotelData = cache(
export const hotelQueryRouter = router({ export const hotelQueryRouter = router({
availability: router({ availability: router({
hotels: serviceProcedure hotelsByCity: serviceProcedure
.input(getHotelsAvailabilityInputSchema) .input(getHotelsAvailabilityInputSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
const { lang } = ctx const { lang } = ctx
@@ -319,6 +328,122 @@ export const hotelQueryRouter = router({
), ),
} }
}), }),
hotelsByHotelIds: serviceProcedure
.input(getHotelsByHotelIdsAvailabilityInputSchema)
.query(async ({ input, ctx }) => {
const { lang } = ctx
const apiLang = toApiLang(lang)
const {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
} = input
const params: Record<string, string | number | number[]> = {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
...(children && { children }),
...(bookingCode && { bookingCode }),
language: apiLang,
}
hotelsByHotelIdAvailabilityCounter.add(1, {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsByHotelIdAvailability start",
JSON.stringify({ query: { params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Availability.hotels(),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
hotelsByHotelIdAvailabilityFailCounter.add(1, {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.hotelsByHotelIdAvailability error",
JSON.stringify({
query: { params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateAvailabilityData =
getHotelsAvailabilitySchema.safeParse(apiJson)
if (!validateAvailabilityData.success) {
hotelsByHotelIdAvailabilityFailCounter.add(1, {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
error_type: "validation_error",
error: JSON.stringify(validateAvailabilityData.error),
})
console.error(
"api.hotels.hotelsByHotelIdAvailability validation error",
JSON.stringify({
query: { params },
error: validateAvailabilityData.error,
})
)
throw badRequestError()
}
hotelsByHotelIdAvailabilitySuccessCounter.add(1, {
hotelIds,
roomStayStartDate,
roomStayEndDate,
adults,
children,
bookingCode,
})
console.info(
"api.hotels.hotelsByHotelIdAvailability success",
JSON.stringify({
query: { params },
})
)
return {
availability: validateAvailabilityData.data.data.flatMap(
(hotels) => hotels.attributes
),
}
}),
rooms: serviceProcedure rooms: serviceProcedure
.input(getRoomsAvailabilityInputSchema) .input(getRoomsAvailabilityInputSchema)
.query(async ({ input, ctx }) => { .query(async ({ input, ctx }) => {
@@ -907,6 +1032,85 @@ export const hotelQueryRouter = router({
) )
}), }),
}), }),
nearbyHotelIds: serviceProcedure
.input(nearbyHotelIdsInput)
.query(async function ({ ctx, input }) {
const { lang } = ctx
const apiLang = toApiLang(lang)
const { hotelId } = input
const params: Record<string, string | number> = {
language: apiLang,
}
nearbyHotelIdsCounter.add(1, {
hotelId,
})
console.info(
"api.hotels.nearbyHotelIds start",
JSON.stringify({ query: { hotelId, params } })
)
const apiResponse = await api.get(
api.endpoints.v1.Hotel.Hotels.nearbyHotels(hotelId),
{
headers: {
Authorization: `Bearer ${ctx.serviceToken}`,
},
},
params
)
if (!apiResponse.ok) {
const text = await apiResponse.text()
nearbyHotelIdsFailCounter.add(1, {
hotelId,
error_type: "http_error",
error: JSON.stringify({
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
}),
})
console.error(
"api.hotels.nearbyHotelIds error",
JSON.stringify({
query: { hotelId, params },
error: {
status: apiResponse.status,
statusText: apiResponse.statusText,
text,
},
})
)
return null
}
const apiJson = await apiResponse.json()
const validateHotelData = getNearbyHotelIdsSchema.safeParse(apiJson)
if (!validateHotelData.success) {
nearbyHotelIdsFailCounter.add(1, {
hotelId,
error_type: "validation_error",
error: JSON.stringify(validateHotelData.error),
})
console.error(
"api.hotels.nearbyHotelIds validation error",
JSON.stringify({
query: { hotelId, params },
error: validateHotelData.error,
})
)
throw badRequestError()
}
nearbyHotelIdsSuccessCounter.add(1, {
hotelId,
})
console.info(
"api.hotels.nearbyHotelIds success",
JSON.stringify({
query: { hotelId, params },
})
)
return validateHotelData.data.map((id: string) => parseInt(id, 10))
}),
locations: router({ locations: router({
get: serviceProcedure.query(async function ({ ctx }) { get: serviceProcedure.query(async function ({ ctx }) {
const searchParams = new URLSearchParams() const searchParams = new URLSearchParams()

View File

@@ -25,6 +25,16 @@ export const hotelsAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-fail" "trpc.hotel.availability.hotels-fail"
) )
export const hotelsByHotelIdAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.hotels-by-hotel-id"
)
export const hotelsByHotelIdAvailabilitySuccessCounter = meter.createCounter(
"trpc.hotel.availability.hotels-by-hotel-id-success"
)
export const hotelsByHotelIdAvailabilityFailCounter = meter.createCounter(
"trpc.hotel.availability.hotels-by-hotel-id-fail"
)
export const roomsAvailabilityCounter = meter.createCounter( export const roomsAvailabilityCounter = meter.createCounter(
"trpc.hotel.availability.rooms" "trpc.hotel.availability.rooms"
) )
@@ -73,6 +83,16 @@ export const getHotelIdsFailCounter = meter.createCounter(
"trpc.hotel.hotel-ids.get-fail" "trpc.hotel.hotel-ids.get-fail"
) )
export const nearbyHotelIdsCounter = meter.createCounter(
"trpc.hotel.nearby-hotel-ids.get"
)
export const nearbyHotelIdsSuccessCounter = meter.createCounter(
"trpc.hotel.nearby-hotel-ids.get-success"
)
export const nearbyHotelIdsFailCounter = meter.createCounter(
"trpc.hotel.nearby-hotel-ids.get-fail"
)
export const meetingRoomsCounter = meter.createCounter( export const meetingRoomsCounter = meter.createCounter(
"trpc.hotels.meetingRooms" "trpc.hotels.meetingRooms"
) )

View File

@@ -6,3 +6,11 @@ export type AvailabilityInput = {
children?: string children?: string
bookingCode?: string bookingCode?: string
} }
export type AlternativeHotelsAvailabilityInput = {
roomStayStartDate: string
roomStayEndDate: string
adults: number
children?: string
bookingCode?: string
}

View File

@@ -6,7 +6,10 @@ import type { imageSchema } from "@/server/routers/hotels/schemas/image"
import type { Child } from "../selectRate/selectRate" import type { Child } from "../selectRate/selectRate"
import type { HotelData } from "./hotelCardListingProps" import type { HotelData } from "./hotelCardListingProps"
import type { CategorizedFilters, Filter } from "./hotelFilters" import type { CategorizedFilters, Filter } from "./hotelFilters"
import type { SelectHotelSearchParams } from "./selectHotelSearchParams" import type {
AlternativeHotelsSearchParams,
SelectHotelSearchParams,
} from "./selectHotelSearchParams"
export interface HotelListingProps { export interface HotelListingProps {
hotels: HotelData[] hotels: HotelData[]
@@ -65,5 +68,6 @@ export interface HotelCardDialogListingProps {
} }
export type SelectHotelMapContainerProps = { export type SelectHotelMapContainerProps = {
searchParams: SelectHotelSearchParams searchParams: SelectHotelSearchParams | AlternativeHotelsSearchParams
isAlternativeHotels?: boolean
} }

View File

@@ -0,0 +1,7 @@
import type { HotelData } from "./hotelCardListingProps"
export type NoAvailabilityAlertProp = {
isAllUnavailable: boolean
isAlternative?: boolean
hotels: HotelData[]
}

View File

@@ -1,6 +1,10 @@
import type { CheckInData, Hotel, ParkingData } from "@/types/hotel" import type { CheckInData, Hotel, ParkingData } from "@/types/hotel"
import type { HotelLocation } from "@/types/trpc/routers/hotel/locations"
import type { Lang } from "@/constants/languages" import type { Lang } from "@/constants/languages"
import type { SelectHotelSearchParams } from "./selectHotelSearchParams" import type {
AlternativeHotelsSearchParams,
SelectHotelSearchParams,
} from "./selectHotelSearchParams"
export enum AvailabilityEnum { export enum AvailabilityEnum {
Available = "Available", Available = "Available",
@@ -42,5 +46,6 @@ export interface SelectHotelProps {
params: { params: {
lang: Lang lang: Lang
} }
searchParams: SelectHotelSearchParams searchParams: SelectHotelSearchParams | AlternativeHotelsSearchParams
isAlternativeHotels?: boolean
} }

View File

@@ -5,5 +5,11 @@ export interface SelectHotelSearchParams {
fromDate: string fromDate: string
toDate: string toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[] rooms: Pick<Room, "adults" | "childrenInRoom">[]
[key: string]: string | string[] | Pick<Room, "adults" | "childrenInRoom">[] }
export interface AlternativeHotelsSearchParams {
hotel: string
fromDate: string
toDate: string
rooms: Pick<Room, "adults" | "childrenInRoom">[]
} }

View File

@@ -1,6 +1,6 @@
import { z } from "zod" import type { z } from "zod"
import { apiLocationsSchema } from "@/server/routers/hotels/output" import type { apiLocationsSchema } from "@/server/routers/hotels/output"
export interface LocationSchema extends z.output<typeof apiLocationsSchema> {} export interface LocationSchema extends z.output<typeof apiLocationsSchema> {}
@@ -9,3 +9,9 @@ export type Location = Locations[number]
export type CityLocation = Location & { type: "cities" } export type CityLocation = Location & { type: "cities" }
export type HotelLocation = Location & { type: "hotels" } export type HotelLocation = Location & { type: "hotels" }
export function isHotelLocation(
location: Location | null
): location is HotelLocation {
return location?.type === "hotels"
}

View File

@@ -29,6 +29,10 @@ const keyedSearchParams = new Map([
["child", "childrenInRoom"], ["child", "childrenInRoom"],
]) ])
export type SelectHotelParams<T> = Omit<T, "hotel"> & {
hotelId: string
} & PartialRoom
export function getKeyFromSearchParam(key: string): string { export function getKeyFromSearchParam(key: string): string {
return keyedSearchParams.get(key) || key return keyedSearchParams.get(key) || key
} }
@@ -46,12 +50,12 @@ export function convertSearchParamsToObj<T extends PartialRoom>(
searchParams: Record<string, string> searchParams: Record<string, string>
) { ) {
const searchParamsObject = Object.entries(searchParams).reduce< const searchParamsObject = Object.entries(searchParams).reduce<
T & PartialRoom SelectHotelParams<T>
>((acc, [key, value]) => { >((acc, [key, value]) => {
// The params are sometimes indexed with a number (for ex: `room[0].adults`), // The params are sometimes indexed with a number (for ex: `room[0].adults`),
// so we need to split them by . or [] // so we need to split them by . or []
const keys = key.replace(/\]/g, "").split(/\[|\./) const keys = key.replace(/\]/g, "").split(/\[|\./)
const firstKey = getKeyFromSearchParam(keys[0]) as keyof T const firstKey = getKeyFromSearchParam(keys[0])
// Room is a special case since it is an array, so we need to handle it separately // Room is a special case since it is an array, so we need to handle it separately
if (firstKey === "rooms") { if (firstKey === "rooms") {
@@ -92,11 +96,11 @@ export function convertSearchParamsToObj<T extends PartialRoom>(
roomObject[index][roomObjectKey] = value roomObject[index][roomObjectKey] = value
} }
} else { } else {
acc[firstKey] = value as T[keyof T] return { ...acc, [firstKey]: value }
} }
return acc return acc
}, {} as T) }, {} as SelectHotelParams<T>)
return searchParamsObject return searchParamsObject
} }